import { execFileSync } from "node:child_process"; import { ClientCapabilities } from "../tool-calls/client-capabilities.js"; import { applyPatch } from "diff"; import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { gitPatchText, MAX_PATCH_FILE_BYTES, patchUpdateFromDiffToolResponse, previewPatchContent, toolUpdateFromDiffToolResponse, v2UpdateFromDiffToolResponse, } from "../diff.js"; import { AcpToolCallRenderer } from "../tool-calls/renderer.js"; import { buildClaudePermissionPresentation } from "../permissions/presentation.js"; import { toolInfoFromToolUse } from "../tools.js"; import { WriteReporter } from "../tool-calls/reporters/file-edit.js"; const tempDirectories: string[] = []; afterEach(async () => { await Promise.all( tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), ); }); async function temporaryFile(content?: string | Buffer, name = "file.ts"): Promise { const directory = await mkdtemp(path.join(os.tmpdir(), "claude-acp-patch-")); tempDirectories.push(directory); const filePath = path.join(directory, name); if (content !== undefined) await writeFile(filePath, content); return filePath; } function patchText(content: unknown): string { const block = Array.isArray(content) ? content[0] : content; if (!block || block.type !== "diff") throw new Error("Expected diff content"); return (block._meta as any).jetbrains.air.diffPatch.text; } /** The header name that git writes for an absolute path. */ function gitName(filePath: string): string { return filePath.replace(/^\/+/u, ""); } describe("approval patch previews", () => { it("builds a compact approval patch from a 12000-line file", async () => { const lines = Array.from({ length: 12_000 }, (_, index) => `line ${index + 1}`); const filePath = await temporaryFile(`${lines.join("\n")}\n`); const content = await previewPatchContent("Edit", { file_path: filePath, old_string: "line 6000\n", new_string: "changed line\n", }); expect(content?.[0]).toMatchObject({ type: "diff", oldText: null, newText: "" }); expect(patchText(content)).toBe( [ `diff --git a/${gitName(filePath)} b/${gitName(filePath)}`, `--- a/${gitName(filePath)}`, `+++ b/${gitName(filePath)}`, "@@ -5997,7 +5997,7 @@", " line 5997", " line 5998", " line 5999", "-line 6000", "+changed line", " line 6001", " line 6002", " line 6003", "", ].join("\n"), ); }); it("builds an approval patch for every replace_all occurrence", async () => { const filePath = await temporaryFile("old\nmiddle\nold\n"); const patch = patchText( await previewPatchContent("Edit", { file_path: filePath, old_string: "old", new_string: "new", replace_all: true, }), ); expect(patch.match(/^-old$/gmu)).toHaveLength(2); expect(patch.match(/^\+new$/gmu)).toHaveLength(2); }); it("builds update and creation approval patches for Write", async () => { const existing = await temporaryFile("before\n"); const missing = await temporaryFile(); const update = patchText( await previewPatchContent("Write", { file_path: existing, content: "after\n" }), ); const creation = patchText( await previewPatchContent("Write", { file_path: missing, content: "created\n" }), ); expect(update).toContain("-before\n+after\n"); expect(creation).toBe( [ `diff --git a/${gitName(missing)} b/${gitName(missing)}`, "new file mode 100644", "--- /dev/null", `+++ b/${gitName(missing)}`, "@@ -0,0 +1 @@", "+created", "", ].join("\n"), ); }); it("reads the Write alias keys that the CLI accepts", async () => { const existing = await temporaryFile("before\n"); for (const input of [ { path: existing, content: "after\n" }, { file_path: existing, file_text: "after\n" }, { path: existing, file_content: "after\n" }, ]) { expect(patchText(await previewPatchContent("Write", input))).toContain("-before\n+after\n"); } }); it("returns no preview when the patch would not be exact", async () => { const duplicate = await temporaryFile("same\nsame\n"); const missing = await temporaryFile(); const crlf = await temporaryFile("one\r\ntwo\r\n"); const binary = await temporaryFile(Buffer.from([0x61, 0x00, 0x62, 0x0a])); const large = await temporaryFile(`${"x".repeat(MAX_PATCH_FILE_BYTES)}\n`); const unchanged = await temporaryFile("before\n"); const previews = await Promise.all([ previewPatchContent("Edit", { file_path: duplicate, old_string: "same", new_string: "n" }), previewPatchContent("Edit", { file_path: missing, old_string: "a", new_string: "b" }), previewPatchContent("Edit", { file_path: crlf, old_string: "one", new_string: "1" }), previewPatchContent("Edit", { file_path: binary, old_string: "a", new_string: "c" }), previewPatchContent("Edit", { file_path: large, old_string: "x", new_string: "y" }), previewPatchContent("Edit", { file_path: unchanged, old_string: "b", new_string: "b" }), previewPatchContent("Edit", { file_path: unchanged, old_string: "", new_string: "b" }), previewPatchContent("Write", { file_path: unchanged, content: "before\n" }), ]); expect(previews).toEqual(Array(previews.length).fill(undefined)); // A Write always shows what it changes: a notice for a file that the // adapter cannot show, and the standard diff of a text without an exact patch. expect(await previewPatchContent("Write", { file_path: crlf, content: "one\n" })).toMatchObject( [{ type: "content" }], ); expect( await previewPatchContent("Write", { file_path: missing, content: "a\r\nb\r\n" }), ).toEqual([{ type: "diff", path: missing, oldText: null, newText: "a\r\nb\r\n" }]); }); it.skipIf(process.platform === "win32")( "returns no preview for a FIFO without waiting for a writer", async () => { const fifo = await temporaryFile(undefined, "pipe"); execFileSync("mkfifo", [fifo]); expect( await previewPatchContent("Edit", { file_path: fifo, old_string: "a", new_string: "b" }), ).toBeUndefined(); }, ); it("returns no preview when a replace_all result can exceed the size limit", async () => { const longText = "b".repeat(4096); const small = await temporaryFile(`${"a".repeat(200)}\n`); const over = await temporaryFile(`${"a".repeat(512)}\n`); // Each of 1 MiB matches grows by 4 KiB: an unguarded replacement builds // 4 GiB before the line diff starts. const huge = await temporaryFile("a".repeat(MAX_PATCH_FILE_BYTES)); const edit = (file_path: string) => previewPatchContent("Edit", { file_path, old_string: "a", new_string: longText, replace_all: true, }); expect(await edit(small)).toBeDefined(); expect(await edit(over)).toBeUndefined(); expect(await edit(huge)).toBeUndefined(); }); it("builds a creation patch for an Edit with an empty old_string", async () => { const missing = await temporaryFile(); const empty = await temporaryFile(""); const creation = patchText( await previewPatchContent("Edit", { file_path: missing, old_string: "", new_string: "a\n" }), ); const fill = patchText( await previewPatchContent("Edit", { file_path: empty, old_string: "", new_string: "a\n" }), ); expect(creation).toContain("new file mode 100644\n--- /dev/null\n"); expect(creation).toContain("@@ -0,0 +1 @@\n+a\n"); expect(fill).toContain(`--- a/${gitName(empty)}\n+++ b/${gitName(empty)}\n@@ -0,0 +1 @@\n`); }); it("keeps the missing final newline marker", async () => { const filePath = await temporaryFile("a\nb"); const patch = patchText( await previewPatchContent("Edit", { file_path: filePath, old_string: "b", new_string: "c" }), ); expect(patch).toContain("-b\n\\ No newline at end of file\n+c\n\\ No newline at end of file\n"); }); it("marks the final newline of a Write that changes only that newline", async () => { const added = await temporaryFile("same"); const removed = await temporaryFile("same\n"); expect( patchText(await previewPatchContent("Write", { file_path: added, content: "same\n" })), ).toContain("@@ -1 +1 @@\n-same\n\\ No newline at end of file\n+same\n"); expect( patchText(await previewPatchContent("Write", { file_path: removed, content: "same" })), ).toContain("@@ -1 +1 @@\n-same\n+same\n\\ No newline at end of file\n"); // The PostToolUse hook builds the same patch from the written file. await writeFile(added, "same\n"); const hook = await patchUpdateFromDiffToolResponse({ type: "update", filePath: added, content: "same\n", originalFile: "same", structuredPatch: [], }); expect(patchText(hook?.content)).toContain( "@@ -1 +1 @@\n-same\n\\ No newline at end of file\n+same\n", ); }); it("uses the tool input as it is", async () => { const code = await temporaryFile("a\nb\n"); const missing = await temporaryFile(); const edit = patchText( await previewPatchContent("Edit", { file_path: code, old_string: "b", new_string: "c " }), ); const write = patchText( await previewPatchContent("Write", { file_path: missing, content: "x \ny\n" }), ); expect(edit).toContain("-b\n+c \n"); expect(write).toContain("@@ -0,0 +1,2 @@\n+x \n+y\n"); }); it("builds the patch of a Write that changes hundreds of lines of a large file", async () => { const lines = Array.from({ length: 5000 }, (_, index) => `line ${index}\n`); const oldText = lines.join(""); const newText = lines .map((line, index) => (index % 25 === 0 ? `changed ${index}\n` : line)) .join(""); const filePath = await temporaryFile(oldText); // Verify the 200-line patch independently of the runner's speed. The // timeout fallback is exercised separately with an advancing clock. const clock = vi.spyOn(Date, "now").mockReturnValue(0); try { const patch = patchText( await previewPatchContent("Write", { file_path: filePath, content: newText }), ); expect(applyPatch(oldText, patch)).toBe(newText); } finally { clock.mockRestore(); } }); it("falls back to the standard Write diff when the patch budget expires", async () => { const oldText = "before\n"; const newText = "after\n"; const filePath = await temporaryFile(oldText); let now = 0; const clock = vi.spyOn(Date, "now").mockImplementation(() => (now += 1000)); try { expect(await previewPatchContent("Write", { file_path: filePath, content: newText })).toEqual( [{ type: "diff", path: filePath, oldText, newText }], ); } finally { clock.mockRestore(); } }); it("builds patches that turn the old text into the new text", async () => { let seed = 7; const random = (limit: number) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % limit; }; for (let round = 0; round < 200; round++) { const oldLines = Array.from({ length: random(40) }, () => `v${random(4)}\n`); const newLines = [...oldLines]; for (let edit = random(4); edit >= 0; edit--) { const at = random(newLines.length + 1); if (random(2) === 0) newLines.splice(at, 1); else newLines.splice(at, 0, `n${random(4)}\n`); } const oldText = oldLines.join("") + (random(3) === 0 ? "tail" : ""); const newText = newLines.join("") + (random(3) === 0 ? "tail" : ""); if (oldText === newText || oldText === "" || newText === "") continue; const filePath = await temporaryFile(oldText); const content = await previewPatchContent("Write", { file_path: filePath, content: newText }); expect(applyPatch(oldText, patchText(content)), `round ${round}`).toBe(newText); } }); it("removes the line break of a line that an empty new_string deletes", async () => { const filePath = await temporaryFile("keep\ndrop\nkeep\n"); const repeated = await temporaryFile("x\nx"); const patch = patchText( await previewPatchContent("Edit", { file_path: filePath, old_string: "drop", new_string: "", }), ); // Only the occurrence that a line break follows goes with replace_all. const all = patchText( await previewPatchContent("Edit", { file_path: repeated, old_string: "x", new_string: "", replace_all: true, }), ); expect(patch).toContain("@@ -1,3 +1,2 @@\n keep\n-drop\n keep\n"); expect(all).toContain("@@ -1,2 +1 @@\n-x\n x\n"); }); it("sends no content when there is no preview", async () => { const missing = await temporaryFile(); const input = { file_path: missing, old_string: "old", new_string: "new" }; const presentation = buildClaudePermissionPresentation({ toolName: "Edit", input, toolUseID: "tool-edit", capabilities: new ClientCapabilities(false, false, true, { client: true, rawInputRendering: false, planFile: false, }), previewContent: await previewPatchContent("Edit", input), }); // The tool_call already carries the standard diff. expect(presentation.toolCall.content).toBeUndefined(); expect(presentation.toolCall.rawInput).toEqual({ file_path: missing }); }); }); describe("git patch headers", () => { const hunk = { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ["-a", "+b"] }; it("strips the leading slash and converts Windows separators", () => { expect(gitPatchText("/work/src/App.ts", "update", [hunk])).toMatch( /^diff --git a\/work\/src\/App\.ts b\/work\/src\/App\.ts\n--- a\/work\/src\/App\.ts\n\+\+\+ b\/work\/src\/App\.ts\n/u, ); expect(gitPatchText("C:\\work\\App.ts", "update", [hunk])).toContain( "diff --git a/C:/work/App.ts b/C:/work/App.ts\n", ); }); it("quotes names like git and marks names with a space", () => { expect(gitPatchText('/work/a"b.ts', "update", [hunk])).toContain( 'diff --git "a/work/a\\"b.ts" "b/work/a\\"b.ts"\n--- "a/work/a\\"b.ts"\n', ); expect(gitPatchText("/work/é.ts", "update", [hunk])).toContain( 'diff --git "a/work/\\303\\251.ts" "b/work/\\303\\251.ts"\n', ); expect(gitPatchText("/work/my file.ts", "update", [hunk])).toContain( "diff --git a/work/my file.ts b/work/my file.ts\n--- a/work/my file.ts\t\n+++ b/work/my file.ts\t\n", ); }); it("writes a created file header", () => { expect(gitPatchText("/f", "create", [{ ...hunk, oldLines: 0, lines: ["+b"] }])).toBe( "diff --git a/f b/f\nnew file mode 100644\n--- /dev/null\n+++ b/f\n@@ -0,0 +1 @@\n+b\n", ); }); it("uses one header form for previews and hook patches", async () => { const filePath = await temporaryFile(); const preview = patchText( await previewPatchContent("Write", { file_path: filePath, content: "x\n" }), ); await writeFile(filePath, "x\n"); const hook = patchText( ( await patchUpdateFromDiffToolResponse({ type: "create", filePath, content: "x\n", structuredPatch: [], originalFile: null, }) )?.content, ); expect(hook).toBe(preview); }); }); describe("tool-call diff content", () => { it("sends the standard diff for an Edit snippet in both modes", () => { const toolUse = { id: "edit", name: "Edit", input: { file_path: "/work/a.ts", old_string: "old", new_string: "new" }, }; const standard = [{ type: "diff", path: "/work/a.ts", oldText: "old", newText: "new" }]; expect(toolInfoFromToolUse(toolUse, false, undefined, true).content).toEqual(standard); expect(toolInfoFromToolUse(toolUse, false, undefined, false).content).toEqual(standard); }); it("sends a Write diff at tool use only to a client without diffPatch", () => { const toolUse = { id: "write", name: "Write", input: { file_path: "/work/a.ts", content: "a\n" }, }; // The input does not tell whether the file exists. The patch comes from the // approval preview or from the PostToolUse hook. expect(toolInfoFromToolUse(toolUse, false, undefined, true).content).toEqual([]); expect(toolInfoFromToolUse(toolUse, false, undefined, false).content).toEqual([ { type: "diff", path: "/work/a.ts", oldText: null, newText: "a\n" }, ]); }); }); describe("ACP v2 diffs", () => { const hunk = { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ["-a", "+b"] }; const v2 = new ClientCapabilities(false, false, false, undefined, true); const v2Diff = (filePath: string, operation: string, patch?: string) => ({ type: "diff", changes: [{ operation, path: filePath, fileType: "text" }], ...(patch !== undefined ? { patch: { format: "git_patch", text: patch } } : {}), }); it("names the absolute path in a git patch, without the a/ and b/ prefixes", () => { expect(gitPatchText("/work/src/App.ts", "update", [hunk], "absolute")).toBe( "diff --git /work/src/App.ts /work/src/App.ts\n--- /work/src/App.ts\n+++ /work/src/App.ts\n@@ -1 +1 @@\n-a\n+b\n", ); expect( gitPatchText("/f", "create", [{ ...hunk, oldLines: 0, lines: ["+b"] }], "absolute"), ).toBe("diff --git /f /f\nnew file mode 100644\n--- /dev/null\n+++ /f\n@@ -0,0 +1 @@\n+b\n"); expect(gitPatchText('/work/a"b.ts', "update", [hunk], "absolute")).toContain( 'diff --git "/work/a\\"b.ts" "/work/a\\"b.ts"\n', ); expect(gitPatchText("/work/my file.ts", "update", [hunk], "absolute")).toContain( "--- /work/my file.ts\t\n+++ /work/my file.ts\t\n", ); }); it("keeps a Windows path native in a git patch, so it matches the changes entry", () => { expect(gitPatchText("C:\\work\\App.ts", "update", [hunk], "absolute")).toContain( 'diff --git "C:\\\\work\\\\App.ts" "C:\\\\work\\\\App.ts"\n--- "C:\\\\work\\\\App.ts"\n+++ "C:\\\\work\\\\App.ts"\n', ); }); it("shows no diff at tool use, where the input holds a snippet or no old text", () => { const renderer = new AcpToolCallRenderer(v2); const edit = { file_path: "/work/a.ts", old_string: "old", new_string: "new" }; const write = { file_path: "/work/a.ts", content: "a\n" }; expect(renderer.toolInfo({ id: "e", name: "Edit", input: edit }).content).toEqual([]); expect(renderer.toolInfo({ id: "w", name: "Write", input: write }).content).toEqual([]); }); it("builds the exact patch of a finished change from the written file", async () => { const filePath = await temporaryFile("line 1\nnew\n"); const update = await v2UpdateFromDiffToolResponse({ filePath, oldString: "old", originalFile: "line 1\nold\n", structuredPatch: [], }); expect(update).toEqual({ content: [ v2Diff( filePath, "modify", `diff --git ${filePath} ${filePath}\n--- ${filePath}\n+++ ${filePath}\n@@ -1,2 +1,2 @@\n line 1\n-old\n+new\n`, ), ], locations: [{ path: filePath, line: 1 }], }); }); it("falls back to the display hunks when the written file cannot give the patch", async () => { const filePath = await temporaryFile(); const update = await v2UpdateFromDiffToolResponse({ filePath, oldString: "a", originalFile: "a\n", structuredPatch: [{ ...hunk, newStart: 4 }], }); expect(update).toEqual({ content: [ v2Diff( filePath, "modify", `diff --git ${filePath} ${filePath}\n--- ${filePath}\n+++ ${filePath}\n@@ -1 +4 @@\n-a\n+b\n`, ), ], locations: [{ path: filePath, line: 4 }], }); }); it("shows a created file whose patch cannot be exact with its whole text", async () => { const filePath = await temporaryFile(); const update = await v2UpdateFromDiffToolResponse({ type: "create", filePath, content: "x\n", structuredPatch: [], originalFile: null, }); expect(update).toEqual({ content: [ v2Diff( filePath, "add", `diff --git ${filePath} ${filePath}\nnew file mode 100644\n--- /dev/null\n+++ ${filePath}\n@@ -0,0 +1 @@\n+x\n`, ), ], locations: [{ path: filePath }], }); }); it("sends the change alone for an update without hunks", async () => { const filePath = await temporaryFile(); // The previous content was too large to include, so there is no patch. const update = await v2UpdateFromDiffToolResponse({ type: "update", filePath, content: "x\n", structuredPatch: [], originalFile: null, }); expect(update).toEqual({ content: [v2Diff(filePath, "modify")], locations: [{ path: filePath }], }); }); it("previews a change in v2 form", async () => { const filePath = await temporaryFile("a\n"); expect( await previewPatchContent("Write", { file_path: filePath, content: "b\n" }, undefined, "v2"), ).toEqual([ v2Diff( filePath, "modify", `diff --git ${filePath} ${filePath}\n--- ${filePath}\n+++ ${filePath}\n@@ -1 +1 @@\n-a\n+b\n`, ), ]); // A text that cannot have an exact patch: the change alone, where v1 gets // the standard diff. expect( await previewPatchContent( "Write", { file_path: filePath, content: "b\r\n" }, undefined, "v2", ), ).toEqual([v2Diff(filePath, "modify")]); }); it("sends no diff where the operation is unknown or nothing changed", async () => { const filePath = await temporaryFile("x\n"); // An Edit with an empty old_string created the file or filled an empty one. expect( await v2UpdateFromDiffToolResponse({ filePath, oldString: "", originalFile: "", structuredPatch: [{ ...hunk, oldLines: 0, lines: ["+x"] }], }), ).toEqual({}); expect( await v2UpdateFromDiffToolResponse({ type: "update", filePath, content: "x\n", structuredPatch: [], originalFile: "x\n", }), ).toEqual({}); }); }); describe("Write tool calls for an existing file", () => { const air = new ClientCapabilities(false, false, true, { client: true, rawInputRendering: false, planFile: false, }); it("does not read the file at tool use", async () => { const filePath = await temporaryFile("before\n"); const toolUse = { id: "write", name: "Write", input: { file_path: filePath, content: "after\n" }, }; expect(toolInfoFromToolUse(toolUse, false, undefined, true).content).toEqual([]); }); it("shows the standard diff in the approval of a Write whose diff exceeds the budget", async () => { // Every line changes, so the line diff runs out of its time budget. const oldText = Array.from({ length: 20_000 }, (_, index) => `old ${index}\n`).join(""); const newText = Array.from({ length: 20_000 }, (_, index) => `new ${index}\n`).join(""); const filePath = await temporaryFile(oldText); const preview = await previewPatchContent("Write", { file_path: filePath, content: newText }); expect(preview).toBeDefined(); expect(preview!.length).toBeGreaterThan(0); }); it("sends no hook patch when the previous text is larger than the limit", async () => { const filePath = await temporaryFile("small\n"); const result = await patchUpdateFromDiffToolResponse({ type: "update", filePath, content: "small\n", structuredPatch: [], originalFile: "x".repeat(MAX_PATCH_FILE_BYTES + 1), }); expect(result).toBeUndefined(); }); it("shows a created file that cannot have a patch in the hook result", async () => { // No approval ran, and CRLF text has no exact patch, so the hook is the // only report that shows the created file. const filePath = await temporaryFile("a\r\nb\r\n"); const result = await new WriteReporter().hookResult( { type: "create", filePath, content: "a\r\nb\r\n", structuredPatch: [], originalFile: null }, { capabilities: air }, ); expect(result.content).toEqual([ { type: "diff", path: filePath, oldText: null, newText: "a\r\nb\r\n" }, ]); }); it("shows in the approval that the Write overwrites a file whose text is unknown", async () => { const filePath = await temporaryFile(Buffer.from([0x61, 0x00, 0x62, 0x0a])); const input = { file_path: filePath, content: "text\n" }; const presentation = buildClaudePermissionPresentation({ toolName: "Write", input, toolUseID: "write", capabilities: air, previewContent: await previewPatchContent("Write", input), }); expect(presentation.toolCall.content).toEqual([ { type: "content", content: { type: "text", text: `Overwrites the existing file \`${filePath}\`. The adapter cannot show its current content.`, }, }, ]); }); it("shows the standard diff in the approval of a Write whose text cannot have a patch", async () => { const filePath = await temporaryFile("before\n"); const input = { file_path: filePath, content: "a\r\nb\r\n" }; expect(await previewPatchContent("Write", input)).toEqual([ { type: "diff", path: filePath, oldText: "before\n", newText: "a\r\nb\r\n" }, ]); }); }); describe("PostToolUse hook patches", () => { it("builds the patch from the written file, not from display hunks", async () => { const filePath = await temporaryFile("\tkeep\n\told\n"); await writeFile(filePath, "\tkeep\n\tnew\n"); const result = await patchUpdateFromDiffToolResponse({ filePath, oldString: "\told", newString: "\tnew", originalFile: "\tkeep\n\told\n", // Claude converts leading tabs to spaces in structuredPatch. structuredPatch: [ { oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, lines: [" keep", "- old", "+ new"], }, ], }); expect(result?.locations).toEqual([{ path: filePath, line: 1 }]); expect(patchText(result?.content)).toContain("@@ -1,2 +1,2 @@\n \tkeep\n-\told\n+\tnew\n"); }); it("builds a creation patch for a Write that created the file", async () => { const filePath = await temporaryFile("a\nb"); const result = await patchUpdateFromDiffToolResponse({ type: "create", filePath, content: "a\nb", structuredPatch: [], originalFile: null, }); expect(patchText(result?.content)).toBe( [ `diff --git a/${gitName(filePath)} b/${gitName(filePath)}`, "new file mode 100644", "--- /dev/null", `+++ b/${gitName(filePath)}`, "@@ -0,0 +1,2 @@", "+a", "+b", "\\ No newline at end of file", "", ].join("\n"), ); }); it("keeps the standard diff for an Edit that fills an empty file", async () => { // Claude reports the same response for a created file and for an existing // empty file, so the hook cannot choose the creation header. const filePath = await temporaryFile("a\n"); const result = await patchUpdateFromDiffToolResponse({ filePath, oldString: "", newString: "a\n", originalFile: "", structuredPatch: [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 1, lines: ["+a"] }], }); expect(result).toBeUndefined(); }); it("builds no patch for a Write that changed nothing", async () => { // The file changed after the Write. The Write itself wrote the same text. const filePath = await temporaryFile("changed later\n"); const response = { type: "update", filePath, content: "same\n", originalFile: "same\n", structuredPatch: [], }; expect(await patchUpdateFromDiffToolResponse(response)).toBeUndefined(); // The standard diff then shows that the text stayed the same. expect(toolUpdateFromDiffToolResponse(response).content).toEqual([ { type: "diff", path: filePath, oldText: "same\n", newText: "same\n" }, ]); }); it("declines a patch that it cannot build exactly", async () => { const crlf = await temporaryFile("a\r\nc\r\n"); const missing = await temporaryFile(); const changed = await temporaryFile("other\n"); const results = await Promise.all([ patchUpdateFromDiffToolResponse({ filePath: crlf, originalFile: "a\nb\n" }), patchUpdateFromDiffToolResponse({ filePath: missing, originalFile: "a\n" }), patchUpdateFromDiffToolResponse({ type: "update", filePath: changed, originalFile: null }), patchUpdateFromDiffToolResponse({ type: "create", filePath: changed, content: "x\n" }), patchUpdateFromDiffToolResponse({ filePath: changed, originalFile: "other\n" }), ]); expect(results).toEqual(Array(results.length).fill(undefined)); }); it("maps structured hunks to the standard diff", () => { expect( toolUpdateFromDiffToolResponse({ filePath: "/file.ts", structuredPatch: [ { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ["-old", "+new"] }, ], }), ).toEqual({ content: [{ type: "diff", path: "/file.ts", oldText: "old", newText: "new" }], locations: [{ path: "/file.ts", line: 1 }], }); }); it("leaves a created file to the tool-call content in the standard mapping", () => { expect( toolUpdateFromDiffToolResponse({ type: "create", filePath: "/file.ts", content: "a\n", structuredPatch: [], originalFile: null, }), ).toEqual({}); }); });