import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { DEFAULT_TOOL_DISPLAY_CONFIG, type ToolDisplayConfig } from "../src/types.ts"; import { getWriteToolDisplayDetails, registerToolDisplayOverrides, } from "../src/tool-overrides.ts"; import { createExtensionApiHarness, createTestTheme, renderComponentText, runEvent, stripAnsi, } from "./tool-renderer-harness.ts"; const THEME = createTestTheme() as never; function makeConfig(overrides: Partial = {}): ToolDisplayConfig { return { ...DEFAULT_TOOL_DISPLAY_CONFIG, boxedToolCalls: false, ...overrides, }; } function setupExtension(cwd: string, config: ToolDisplayConfig = makeConfig()): ReturnType { const harness = createExtensionApiHarness(); registerToolDisplayOverrides(harness.api, () => config); return harness; } async function recordWrite( harness: ReturnType, cwd: string, toolCallId: string, input: Record, details?: unknown, ): Promise { await runEvent(harness, "tool_call", { toolName: "write", toolCallId, input }, { cwd }); const results = await runEvent( harness, "tool_result", { toolName: "write", toolCallId, input, details, isError: false }, { cwd }, ); const first = results[0] as { details?: unknown } | undefined; return first?.details; } function renderWriteResult( harness: ReturnType, cwd: string, args: Record, details: unknown, toolCallId = "w1", state: Record = {}, expanded = false, ): string { const renderers = harness.resolvers[0]("write", () => undefined); assert.ok(renderers?.renderResult); return stripAnsi( renderComponentText( renderers.renderResult( { content: [{ type: "text", text: `Successfully wrote to ${String(args.path)}` }], details, } as never, { expanded, isPartial: false } as never, THEME, { args, toolCallId, cwd, state } as never, ), 120, ), ); } test("a write diff renders from details.toolDisplay after a reload (empty in-memory map)", async () => { const cwd = mkdtempSync(join(tmpdir(), "pi-tool-display-write-reload-")); try { writeFileSync(join(cwd, "demo.txt"), "before line\n"); const live = setupExtension(cwd); const details = await recordWrite(live, cwd, "w1", { path: "demo.txt", content: "after line\n", }); // Simulated reload: a fresh registration with a fresh in-memory map. const reloaded = setupExtension(cwd); const args = { path: "demo.txt", content: "after line\n" }; const withoutRecord = renderWriteResult(reloaded, cwd, args, {}); const withRecord = renderWriteResult(reloaded, cwd, args, details); assert.notEqual(withRecord, withoutRecord); assert.match(withRecord, /before line/); assert.doesNotMatch(withoutRecord, /before line/); assert.match(withRecord, /after line/); // A result from an older session (no record at all) still renders safely. assert.doesNotThrow(() => renderWriteResult(reloaded, cwd, args, undefined, "legacy")); } finally { rmSync(cwd, { recursive: true, force: true }); } }); test("a previous file larger than 64 KiB persists an omission marker instead of the content", async () => { const cwd = mkdtempSync(join(tmpdir(), "pi-tool-display-write-large-")); try { const previousContent = `${"old line\n".repeat(9000)}`; writeFileSync(join(cwd, "big.txt"), previousContent); const harness = setupExtension(cwd); const details = await recordWrite(harness, cwd, "w1", { path: "big.txt", content: "replacement\n", }); const record = getWriteToolDisplayDetails(details); assert.ok(record); assert.equal(record.fileExistedBeforeWrite, true); assert.equal(record.previousContentOmitted, true); assert.equal(record.previousContent, undefined); assert.equal(record.previousBytes, Buffer.byteLength(previousContent, "utf8")); assert.ok((record.previousBytes ?? 0) > 64 * 1024); const rendered = renderWriteResult(harness, cwd, { path: "big.txt", content: "replacement\n" }, details); assert.ok(rendered.length < 100 * 1024, `rendered output should stay bounded, got ${rendered.length} chars`); assert.match(rendered, /overwrite diff omitted \(9000 → 1 lines\)/); assert.doesNotMatch(rendered, /old line/); } finally { rmSync(cwd, { recursive: true, force: true }); } }); test("a previous file above the preview read limit renders an explicit omission live and after reload", async () => { const cwd = mkdtempSync(join(tmpdir(), "pi-tool-display-write-too-large-")); try { const previousContent = "old line\n".repeat(130_000); assert.ok(Buffer.byteLength(previousContent, "utf8") > 1_000_000); writeFileSync(join(cwd, "huge.txt"), previousContent); const live = setupExtension(cwd); const details = await recordWrite(live, cwd, "huge", { path: "huge.txt", content: "replacement\n", }); const record = getWriteToolDisplayDetails(details); assert.deepEqual(record, { fileExistedBeforeWrite: true, previousContentOmitted: true, }); const args = { path: "huge.txt", content: "replacement\n" }; // Live rendering uses the in-memory capture, not the persisted record. const liveRendered = renderWriteResult(live, cwd, args, {}, "huge", {}, true); const reloaded = setupExtension(cwd); const reloadedRendered = renderWriteResult(reloaded, cwd, args, details, "huge-reload", {}, true); for (const rendered of [liveRendered, reloadedRendered]) { assert.match(rendered, /previous content omitted/); assert.match(rendered, /showing new content only/); assert.match(rendered, /replacement/); assert.doesNotMatch(rendered, /↳ created/); assert.doesNotMatch(rendered, /↳ overwritten(?!.*previous content omitted)/); assert.doesNotMatch(rendered, /old line/); assert.doesNotMatch(rendered, /^\s*[+-]\s+\S/m); } } finally { rmSync(cwd, { recursive: true, force: true }); } }); test("a legacy persisted true-only record uses the omission renderer", () => { const cwd = mkdtempSync(join(tmpdir(), "pi-tool-display-write-true-only-")); try { const harness = setupExtension(cwd); const rendered = renderWriteResult( harness, cwd, { path: "legacy.txt", content: "replacement\n" }, { toolDisplay: { fileExistedBeforeWrite: true } }, "legacy-true", {}, true, ); assert.match(rendered, /previous content omitted/); assert.match(rendered, /showing new content only/); assert.doesNotMatch(rendered, /↳ created/); assert.doesNotMatch(rendered, /↳ overwritten(?!.*previous content omitted)/); assert.doesNotMatch(rendered, /^\s*[+-]\s+\S/m); } finally { rmSync(cwd, { recursive: true, force: true }); } }); test("a persisted new-file false record still renders the created diff", () => { const cwd = mkdtempSync(join(tmpdir(), "pi-tool-display-write-new-record-")); try { const harness = setupExtension(cwd); const rendered = renderWriteResult( harness, cwd, { path: "new.txt", content: "fresh\n" }, { toolDisplay: { fileExistedBeforeWrite: false } }, "new-record", {}, true, ); assert.doesNotMatch(rendered, /previous content omitted/); assert.match(rendered, /↳ created/); assert.match(rendered, /fresh/); } finally { rmSync(cwd, { recursive: true, force: true }); } }); test("an omitted previous content after reload is labelled instead of drawn as a full overwrite", async () => { const cwd = mkdtempSync(join(tmpdir(), "pi-tool-display-write-omitted-")); try { const previousContent = `${"old line\n".repeat(9000)}`; const newContent = `${Array.from({ length: 10 }, (_, index) => `new line ${index}`).join("\n")}\n`; writeFileSync(join(cwd, "big.txt"), previousContent); const live = setupExtension(cwd); const details = await recordWrite(live, cwd, "w1", { path: "big.txt", content: newContent, }); const record = getWriteToolDisplayDetails(details); assert.ok(record); assert.equal(record.previousContentOmitted, true); // Simulated reload: a fresh extension has no in-memory record, so this renders // exclusively from details.toolDisplay. const reloaded = setupExtension(cwd, makeConfig({ diffCollapsedLines: 4 })); const args = { path: "big.txt", content: newContent }; const collapsed = renderWriteResult(reloaded, cwd, args, details, "w-reload", {}, false); const expanded = renderWriteResult(reloaded, cwd, args, details, "w-reload", {}, true); for (const rendered of [collapsed, expanded]) { assert.match(rendered, /previous content omitted/); assert.match(rendered, /79\.1KB/); assert.match(rendered, /new content:/); assert.doesNotMatch(rendered, /old line/); assert.doesNotMatch(rendered, /created/); assert.doesNotMatch(rendered, /^\s*[+-]\s*\d+\|/m); } assert.match(collapsed, /new line 0/); assert.doesNotMatch(collapsed, /new line 9/); assert.match(collapsed, /6 more lines \(Ctrl\+O to expand\)/); assert.match(expanded, /new line 9/); } finally { rmSync(cwd, { recursive: true, force: true }); } }); test("the in-memory write record takes precedence over stale details", async () => { const cwd = mkdtempSync(join(tmpdir(), "pi-tool-display-write-precedence-")); try { writeFileSync(join(cwd, "demo.txt"), "live previous\n"); const harness = setupExtension(cwd); const details = await recordWrite(harness, cwd, "w1", { path: "demo.txt", content: "next\n", }); const staleDetails = { toolDisplay: { fileExistedBeforeWrite: true, previousContent: "stale previous\n" }, }; const rendered = renderWriteResult( harness, cwd, { path: "demo.txt", content: "next\n" }, { ...(details as object), ...staleDetails }, "w1", ); assert.match(rendered, /live previous/); assert.doesNotMatch(rendered, /stale previous/); // Rendering consumed the live record (the host keeps per-row state), so a new // row with the same call id falls back to the persisted details. const afterConsume = renderWriteResult( harness, cwd, { path: "demo.txt", content: "next\n" }, { ...(details as object), ...staleDetails }, "w1", ); assert.match(afterConsume, /stale previous/); } finally { rmSync(cwd, { recursive: true, force: true }); } });