Files
my-pi/pi-permission-system/test/tool-preview-formatter.test.ts

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,
});
});
});