mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
430 lines
16 KiB
TypeScript
430 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
|
|
|
|
// Mock the prompt-path serializer before importing the module under test.
|
|
// The rest of the module stays real — log-redaction.ts builds on
|
|
// createJsonSafeReplacer, and a literal factory would blank it out.
|
|
vi.mock("../src/json-safe-stringify.js", async (importActual) => ({
|
|
...(await importActual<typeof import("../src/json-safe-stringify.js")>()),
|
|
safeJsonStringify: vi.fn((value: unknown) => JSON.stringify(value)),
|
|
}));
|
|
|
|
import { safeJsonStringify } from "#src/json-safe-stringify";
|
|
import {
|
|
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
} from "#src/tool-input-preview";
|
|
import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
|
|
import type { PermissionCheckResult } from "#src/types";
|
|
import {
|
|
makeToolPreviewFormatter as makeFormatter,
|
|
makePermissionCheckResult,
|
|
} from "#test/helpers/presentation-fixtures";
|
|
|
|
const mockedStringify = vi.mocked(safeJsonStringify);
|
|
|
|
// This file's subject is the allow-path preview, so the wrapper defaults to
|
|
// `allow`.
|
|
function makeResult(
|
|
toolName: string,
|
|
overrides: Partial<PermissionCheckResult> = {},
|
|
): PermissionCheckResult {
|
|
return makePermissionCheckResult(toolName, { state: "allow", ...overrides });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockedStringify.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ── sanitizeInlineText ────────────────────────────────────────────────────
|
|
|
|
describe("ToolPreviewFormatter.sanitizeInlineText", () => {
|
|
test("collapses whitespace and trims", () => {
|
|
const f = makeFormatter();
|
|
expect(f.sanitizeInlineText(" hello world ")).toBe("hello world");
|
|
});
|
|
|
|
test("returns 'empty text' for blank string", () => {
|
|
const f = makeFormatter();
|
|
expect(f.sanitizeInlineText("")).toBe("empty text");
|
|
expect(f.sanitizeInlineText(" ")).toBe("empty text");
|
|
});
|
|
|
|
test("truncates at constructor toolTextSummaryMaxLength", () => {
|
|
const f = makeFormatter({ toolTextSummaryMaxLength: 5 });
|
|
const result = f.sanitizeInlineText("hello world");
|
|
expect(result).toBe("hello…");
|
|
});
|
|
|
|
test("explicit maxLength override takes precedence over constructor default", () => {
|
|
const f = makeFormatter({ toolTextSummaryMaxLength: 80 });
|
|
const result = f.sanitizeInlineText("hello world", 5);
|
|
expect(result).toBe("hello…");
|
|
});
|
|
});
|
|
|
|
// ── formatJsonInputForPrompt ──────────────────────────────────────────────
|
|
|
|
describe("ToolPreviewFormatter.formatJsonInputForPrompt", () => {
|
|
test("returns empty string when serialization yields empty", () => {
|
|
mockedStringify.mockReturnValue(undefined);
|
|
const f = makeFormatter();
|
|
expect(f.formatJsonInputForPrompt({})).toBe("");
|
|
});
|
|
|
|
test("returns prefixed JSON with 'with input' prefix", () => {
|
|
mockedStringify.mockReturnValue('{"k":"v"}');
|
|
const f = makeFormatter();
|
|
expect(f.formatJsonInputForPrompt({ k: "v" })).toBe('with input {"k":"v"}');
|
|
});
|
|
|
|
test("truncates at constructor toolInputPreviewMaxLength", () => {
|
|
const longJson = `"${"x".repeat(20)}"`;
|
|
mockedStringify.mockReturnValue(longJson);
|
|
const f = makeFormatter({ toolInputPreviewMaxLength: 10 });
|
|
const result = f.formatJsonInputForPrompt({});
|
|
// "with input " + 10 chars + ellipsis
|
|
const preview = result.slice("with input ".length);
|
|
expect(preview.length).toBe(11); // 10 + 1 for "…"
|
|
expect(preview.endsWith("…")).toBe(true);
|
|
});
|
|
|
|
test("does not truncate when within toolInputPreviewMaxLength", () => {
|
|
mockedStringify.mockReturnValue('{"k":"v"}');
|
|
const f = makeFormatter({ toolInputPreviewMaxLength: 200 });
|
|
expect(f.formatJsonInputForPrompt({ k: "v" })).toBe('with input {"k":"v"}');
|
|
});
|
|
});
|
|
|
|
// ── formatSearchInputForPrompt ────────────────────────────────────────────
|
|
|
|
describe("ToolPreviewFormatter.formatSearchInputForPrompt", () => {
|
|
test("includes pattern and path", () => {
|
|
const f = makeFormatter();
|
|
const result = f.formatSearchInputForPrompt("grep", {
|
|
pattern: "TODO",
|
|
path: "/src",
|
|
});
|
|
expect(result).toContain("pattern 'TODO'");
|
|
expect(result).toContain("path '/src'");
|
|
});
|
|
|
|
test("truncates pattern at toolTextSummaryMaxLength", () => {
|
|
const f = makeFormatter({ toolTextSummaryMaxLength: 5 });
|
|
const result = f.formatSearchInputForPrompt("grep", {
|
|
pattern: "abcdefgh",
|
|
});
|
|
expect(result).toContain("abcde…");
|
|
});
|
|
|
|
test("uses 'current working directory' for find/grep/ls without path", () => {
|
|
const f = makeFormatter();
|
|
for (const toolName of ["find", "grep", "ls"]) {
|
|
const result = f.formatSearchInputForPrompt(toolName, {});
|
|
expect(result).toContain("current working directory");
|
|
}
|
|
});
|
|
|
|
test("returns empty string for unknown tool with no input", () => {
|
|
const f = makeFormatter();
|
|
expect(f.formatSearchInputForPrompt("other", {})).toBe("");
|
|
});
|
|
});
|
|
|
|
// ── formatToolInputForPrompt ──────────────────────────────────────────────
|
|
|
|
describe("ToolPreviewFormatter.formatToolInputForPrompt", () => {
|
|
test("dispatches 'edit' to standalone formatEditInputForPrompt", () => {
|
|
mockedStringify.mockReturnValue(undefined);
|
|
const f = makeFormatter();
|
|
const result = f.formatToolInputForPrompt("edit", {
|
|
path: "/foo.ts",
|
|
edits: [],
|
|
});
|
|
expect(result).toContain("for '/foo.ts'");
|
|
});
|
|
|
|
test("dispatches 'write' to standalone formatWriteInputForPrompt", () => {
|
|
const f = makeFormatter();
|
|
const result = f.formatToolInputForPrompt("write", {
|
|
path: "/out.ts",
|
|
content: "hi",
|
|
});
|
|
expect(result).toContain("for '/out.ts'");
|
|
});
|
|
|
|
test("dispatches 'read' to standalone formatReadInputForPrompt", () => {
|
|
const f = makeFormatter();
|
|
const result = f.formatToolInputForPrompt("read", { path: "/src/x.ts" });
|
|
expect(result).toContain("path '/src/x.ts'");
|
|
});
|
|
|
|
test("dispatches 'find'/'grep'/'ls' to formatSearchInputForPrompt", () => {
|
|
const f = makeFormatter();
|
|
for (const tool of ["find", "grep", "ls"]) {
|
|
const result = f.formatToolInputForPrompt(tool, {});
|
|
expect(result).toContain("current working directory");
|
|
}
|
|
});
|
|
|
|
test("falls back to formatJsonInputForPrompt for unknown tools", () => {
|
|
mockedStringify.mockReturnValue('{"x":1}');
|
|
const f = makeFormatter();
|
|
const result = f.formatToolInputForPrompt("unknown", { x: 1 });
|
|
expect(result).toContain('{"x":1}');
|
|
});
|
|
|
|
test("unknown tool truncates at constructor toolInputPreviewMaxLength", () => {
|
|
const longJson = `{"k":"${"x".repeat(50)}"}`;
|
|
mockedStringify.mockReturnValue(longJson);
|
|
const f = makeFormatter({ toolInputPreviewMaxLength: 10 });
|
|
const result = f.formatToolInputForPrompt("custom", {});
|
|
const preview = result.slice("with input ".length);
|
|
expect(preview.endsWith("…")).toBe(true);
|
|
expect(preview.length).toBe(11); // 10 + "…"
|
|
});
|
|
});
|
|
|
|
// ── formatToolInputForPrompt (custom formatter seam) ───────────────────────
|
|
|
|
describe("ToolPreviewFormatter.formatToolInputForPrompt — custom formatter seam", () => {
|
|
function makeLookup(
|
|
toolName: string,
|
|
result: string | undefined,
|
|
): ToolInputFormatterLookup {
|
|
return {
|
|
get: (name) => (name === toolName ? () => result : undefined),
|
|
};
|
|
}
|
|
|
|
test("uses a custom formatter's string result verbatim, bypassing the switch", () => {
|
|
const lookup = makeLookup("my-tool", "custom preview");
|
|
const f = makeFormatter({}, lookup);
|
|
expect(f.formatToolInputForPrompt("my-tool", {})).toBe("custom preview");
|
|
});
|
|
|
|
test("falls through to the built-in switch when custom formatter returns undefined", () => {
|
|
mockedStringify.mockReturnValue('{"x":1}');
|
|
const lookup = makeLookup("unknown-tool", undefined);
|
|
const f = makeFormatter({}, lookup);
|
|
// Falls through to JSON default for unknown tools
|
|
expect(f.formatToolInputForPrompt("unknown-tool", { x: 1 })).toContain(
|
|
'{"x":1}',
|
|
);
|
|
});
|
|
|
|
test("custom formatter for a built-in tool overrides the built-in preview", () => {
|
|
const lookup = makeLookup("read", "custom read summary");
|
|
const f = makeFormatter({}, lookup);
|
|
// Would normally use formatReadInputForPrompt; custom overrides it
|
|
expect(f.formatToolInputForPrompt("read", { path: "/foo.ts" })).toBe(
|
|
"custom read summary",
|
|
);
|
|
});
|
|
|
|
test("absent lookup preserves current behaviour for all tool types", () => {
|
|
const f = makeFormatter();
|
|
// Built-in path still works
|
|
expect(f.formatToolInputForPrompt("read", { path: "/foo.ts" })).toContain(
|
|
"/foo.ts",
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── formatGenericToolInputForLog ──────────────────────────────────────────
|
|
|
|
describe("ToolPreviewFormatter.formatGenericToolInputForLog", () => {
|
|
// The log path serializes through redactedJsonStringify, not the mocked
|
|
// prompt-path serializer, so these exercise real serialization.
|
|
test("returns undefined when serialization yields empty string", () => {
|
|
const f = makeFormatter();
|
|
expect(f.formatGenericToolInputForLog({})).toBeUndefined();
|
|
});
|
|
|
|
test("returns prefixed input preview", () => {
|
|
const f = makeFormatter();
|
|
expect(f.formatGenericToolInputForLog({ k: "v" })).toBe('input {"k":"v"}');
|
|
});
|
|
|
|
test("does not truncate — the log writer bounds what it persists", () => {
|
|
const f = makeFormatter();
|
|
const result = f.formatGenericToolInputForLog({ k: "x".repeat(2000) });
|
|
expect(result).toBe(`input {"k":"${"x".repeat(2000)}"}`);
|
|
});
|
|
|
|
test("masks a sensitive-keyed value in the logged preview", () => {
|
|
const f = makeFormatter();
|
|
expect(
|
|
f.formatGenericToolInputForLog({
|
|
url: "https://example.test",
|
|
authorization: "Bearer TEST_VALUE",
|
|
}),
|
|
).toBe('input {"url":"https://example.test","authorization":"[redacted]"}');
|
|
});
|
|
});
|
|
|
|
// ── prompt-vs-log redaction boundary ──────────────────────────────────────
|
|
|
|
describe("ToolPreviewFormatter redaction boundary", () => {
|
|
// Both paths serialize for real here: the invariant is about what each one
|
|
// emits, so asserting against a stubbed serializer would prove nothing.
|
|
beforeEach(() => {
|
|
mockedStringify.mockImplementation((value: unknown) =>
|
|
JSON.stringify(value),
|
|
);
|
|
});
|
|
|
|
const input = { authorization: "Bearer TEST_VALUE" };
|
|
|
|
test("masks the value in the log preview but not in the ask-prompt", () => {
|
|
const f = makeFormatter();
|
|
|
|
expect(f.formatGenericToolInputForLog(input)).toBe(
|
|
'input {"authorization":"[redacted]"}',
|
|
);
|
|
// The user must see the real input to make a permission decision.
|
|
expect(f.formatToolInputForPrompt("http", input)).toBe(
|
|
'with input {"authorization":"Bearer TEST_VALUE"}',
|
|
);
|
|
});
|
|
|
|
test("masks a generic tool's input reaching the review log", () => {
|
|
const f = makeFormatter();
|
|
|
|
expect(
|
|
f.getToolInputPreviewForLog(
|
|
makeResult("http"),
|
|
input,
|
|
new Set(["read", "write", "edit"]),
|
|
),
|
|
).toBe('input {"authorization":"[redacted]"}');
|
|
});
|
|
});
|
|
|
|
// ── getToolInputPreviewForLog ─────────────────────────────────────────────
|
|
|
|
describe("ToolPreviewFormatter.getToolInputPreviewForLog", () => {
|
|
const pathBearingTools = new Set(["read", "write", "edit"]);
|
|
|
|
test("returns undefined for bash tool", () => {
|
|
const f = makeFormatter();
|
|
expect(
|
|
f.getToolInputPreviewForLog(
|
|
makeResult("bash"),
|
|
{ command: "ls" },
|
|
pathBearingTools,
|
|
),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test("returns undefined for mcp tool", () => {
|
|
const f = makeFormatter();
|
|
expect(
|
|
f.getToolInputPreviewForLog(makeResult("mcp"), {}, pathBearingTools),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test("returns undefined for mcp source", () => {
|
|
const f = makeFormatter();
|
|
const result = makeResult("some-server:some-tool", { source: "mcp" });
|
|
expect(
|
|
f.getToolInputPreviewForLog(result, {}, pathBearingTools),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test("returns path-based preview for path-bearing tools", () => {
|
|
const f = makeFormatter();
|
|
const preview = f.getToolInputPreviewForLog(
|
|
makeResult("read"),
|
|
{ path: "/src/foo.ts" },
|
|
pathBearingTools,
|
|
);
|
|
expect(preview).toContain("/src/foo.ts");
|
|
});
|
|
|
|
test("does not truncate a path preview — the log writer bounds it", () => {
|
|
const f = makeFormatter();
|
|
const longPath = `/src/${"a".repeat(2000)}.ts`;
|
|
const preview = f.getToolInputPreviewForLog(
|
|
makeResult("read"),
|
|
{ path: longPath },
|
|
pathBearingTools,
|
|
);
|
|
expect(preview).toBeDefined();
|
|
expect(preview).toContain(longPath);
|
|
});
|
|
|
|
test("returns generic JSON preview for non-path-bearing tools", () => {
|
|
mockedStringify.mockReturnValue('{"n":1}');
|
|
const f = makeFormatter();
|
|
const preview = f.getToolInputPreviewForLog(
|
|
makeResult("task"),
|
|
{ n: 1 },
|
|
pathBearingTools,
|
|
);
|
|
expect(preview).toContain('{"n":1}');
|
|
});
|
|
});
|
|
|
|
// ── getPermissionLogContext ───────────────────────────────────────────────
|
|
|
|
describe("ToolPreviewFormatter.getPermissionLogContext", () => {
|
|
const pathBearingTools = new Set(["read", "write", "edit"]);
|
|
|
|
test("returns command, target, toolInputPreview, and origin fields", () => {
|
|
const f = makeFormatter();
|
|
const result = makeResult("bash", { command: "ls -la" });
|
|
const ctx = f.getPermissionLogContext(result, {}, pathBearingTools);
|
|
expect(ctx.command).toBe("ls -la");
|
|
expect(ctx.target).toBeUndefined();
|
|
expect(ctx.toolInputPreview).toBeUndefined();
|
|
});
|
|
|
|
test("includes toolInputPreview for non-bash path-bearing tools", () => {
|
|
const f = makeFormatter();
|
|
const result = makeResult("read");
|
|
const ctx = f.getPermissionLogContext(
|
|
result,
|
|
{ path: "/foo.ts" },
|
|
pathBearingTools,
|
|
);
|
|
expect(ctx.toolInputPreview).toContain("/foo.ts");
|
|
});
|
|
|
|
test("includes origin from check result", () => {
|
|
const f = makeFormatter();
|
|
const result = makeResult("read", { origin: "project" });
|
|
const ctx = f.getPermissionLogContext(result, {}, pathBearingTools);
|
|
expect(ctx.origin).toBe("project");
|
|
});
|
|
|
|
test("toolInputPreview carries the whole path, for the writer to bound", () => {
|
|
const f = makeFormatter();
|
|
const longPath = `/src/${"a".repeat(2000)}.ts`;
|
|
const ctx = f.getPermissionLogContext(
|
|
makeResult("read"),
|
|
{ path: longPath },
|
|
pathBearingTools,
|
|
);
|
|
expect(ctx.toolInputPreview).toContain(longPath);
|
|
});
|
|
});
|
|
|
|
// ── resolveToolPreviewLimits ───────────────────────────────────────────────
|
|
|
|
// The two operator-facing caps are subsumed by the renderer budgets (ADR 0011
|
|
// §5), so the limits are now the built-in constants alone — no config is read.
|
|
describe("resolveToolPreviewLimits", () => {
|
|
test("returns the three built-in constants", () => {
|
|
expect(resolveToolPreviewLimits()).toEqual({
|
|
toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
});
|
|
});
|
|
});
|