mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-04 08:22:03 +00:00
ACP v2 now says each `git_patch` path, once unquoted, is the same native absolute path string as the matching `changes` entry (agentclientprotocol/agent-client-protocol#2650). The v2 form converted `C:\work\App.ts` to `C:/work/App.ts`, so on Windows the patch and `changes[].path` named different strings. The v2 form now keeps the native path, which Git quoting writes as `"C:\\work\\App.ts"`. Git's own form, which the AIR extension uses, still uses forward slashes and the `a/`/`b/` prefixes.
829 lines
30 KiB
TypeScript
829 lines
30 KiB
TypeScript
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<string> {
|
|
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({});
|
|
});
|
|
});
|