/** * KHAELOR * File: tests/tools/edit.test.ts * Description: Unit tests for the edit tool — one fixture per replacer strategy, plus guards and failure prose. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { readFileSync, rmSync, writeFileSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createEditTool } from "../../src/tools/index.js"; import type { EditInput } from "../../src/tools/index.js"; import type { TestHarness } from "./helpers.js"; import { fixture, makeHarness, markRead } from "./helpers.js"; let h: TestHarness; const edit = createEditTool(); beforeEach(() => { h = makeHarness(); }); afterEach(() => { rmSync(h.dir, { recursive: true, force: true }); }); async function editReadFile(rel: string, content: string, input: Omit) { const abs = fixture(h, rel, content); await markRead(h, abs); const result = await edit.execute({ file_path: rel, ...input }, h.ctx); return { abs, result, disk: () => readFileSync(abs, "utf8") }; } describe("edit tool — replacer cascade", () => { it("strategy 1: exact match", async () => { const { result, disk } = await editReadFile( "exact.ts", "function alpha() {\n const value = 1;\n return value;\n}\n", { old_string: "const value = 1;", new_string: "const value = 42;" }, ); expect(result.isError).toBeUndefined(); expect(result.content).toContain("(1 replacement, matched exactly)."); expect(disk()).toContain("const value = 42;"); expect(result.metadata?.extra?.["strategy"]).toBe("exact"); }); it("strategy 2: line-trimmed (trailing-whitespace drift)", async () => { const { result, disk } = await editReadFile( "trimmed.ts", " const value = 1;\n return value;\n", { old_string: " const value = 1; \n return value;", new_string: " const value = 2;\n return value;", }, ); expect(result.isError).toBeUndefined(); expect(result.content).toContain("matched with line-trimmed whitespace"); expect(disk()).toContain("const value = 2;"); expect(result.metadata?.extra?.["strategy"]).toBe("line-trimmed"); }); it("strategy 3: whitespace-normalized (single-line interior drift)", async () => { const { result, disk } = await editReadFile("wsnorm.ts", " const value = 1;\n", { old_string: "const value = 1;", new_string: "const value = 42;", }); expect(result.isError).toBeUndefined(); expect(result.content).toContain("matched with whitespace normalization"); // The file's own leading indentation is preserved. expect(disk()).toBe(" const value = 42;\n"); expect(result.metadata?.extra?.["strategy"]).toBe("whitespace-normalized"); }); it("strategy 4: indentation-flexible (block quoted at the wrong depth, re-indented)", async () => { const { result, disk } = await editReadFile( "indent.ts", "function run() {\n if (ready) {\n launch();\n }\n}\n", { old_string: "if (ready) {\n launch();\n}", new_string: "if (ready) {\n launchAll();\n}", }, ); expect(result.isError).toBeUndefined(); expect(result.content).toContain("matched with indentation flexibility"); // new_string re-indented to the FILE's depth (4 spaces), preserving relative indent. expect(disk()).toBe("function run() {\n if (ready) {\n launchAll();\n }\n}\n"); expect(result.metadata?.extra?.["strategy"]).toBe("indentation-flexible"); }); it("strategy 5: escape-normalized (over-escaped old_string)", async () => { const { result, disk } = await editReadFile("escape.ts", 'console.log("hi");\n', { old_string: 'console.log(\\"hi\\");', new_string: 'console.log(\\"bye\\");', }); expect(result.isError).toBeUndefined(); expect(result.content).toContain("matched with escape normalization"); expect(disk()).toBe('console.log("bye");\n'); expect(result.metadata?.extra?.["strategy"]).toBe("escape-normalized"); }); it("strategy 6: trimmed-boundary (stray whitespace around old_string)", async () => { const { result, disk } = await editReadFile( "boundary.ts", "// header\nconst value = 1;\nreturn value;\n", { old_string: " \nconst value = 1;\nreturn value;", new_string: " \nconst value = 9;\nreturn value;", }, ); expect(result.isError).toBeUndefined(); expect(result.content).toContain("matched at trimmed boundaries"); expect(disk()).toBe("// header\nconst value = 9;\nreturn value;\n"); expect(result.metadata?.extra?.["strategy"]).toBe("trimmed-boundary"); }); it("strategy 7: block-anchor (fuzzy middle, exact anchors)", async () => { const { result, disk } = await editReadFile( "anchor.ts", "function gamma() {\n const g = 10; // x\n return g;\n}\n", { old_string: "function gamma() {\n const g = 10;\n return g;\n}", new_string: "function gamma() {\n const g = 20;\n return g;\n}", }, ); expect(result.isError).toBeUndefined(); expect(result.content).toContain("matched with block anchors"); expect(disk()).toContain("const g = 20;"); expect(result.metadata?.extra?.["strategy"]).toBe("block-anchor"); }); it("strategy 8: context-aware (anchors + ≥50% of middle lines)", async () => { const { result, disk } = await editReadFile( "context.ts", "function delta() {\n const d1 = 1;\n const totallyDifferentLineHereZZZZZZ = 999;\n return d1 + d2;\n}\n", { old_string: "function delta() {\n const d1 = 1;\n const d2 = 2;\n return d1 + d2;\n}", new_string: "function delta() {\n return 3;\n}", }, ); expect(result.isError).toBeUndefined(); expect(result.content).toContain("matched with context anchors"); expect(disk()).toBe("function delta() {\n return 3;\n}\n"); expect(result.metadata?.extra?.["strategy"]).toBe("context-aware"); }); it("strategy 9: replace_all replaces every exact occurrence and reports the count", async () => { const { result, disk } = await editReadFile( "rename.ts", "foo();\nconst a = foo;\nexport { foo };\n", { old_string: "foo", new_string: "bar", replace_all: true }, ); expect(result.isError).toBeUndefined(); expect(result.content).toContain("(3 replacements)."); expect(disk()).toBe("bar();\nconst a = bar;\nexport { bar };\n"); expect(result.metadata?.extra?.["strategy"]).toBe("multi-occurrence"); expect(result.metadata?.extra?.["replacements"]).toBe(3); }); it("replace_all never falls back to fuzzy matching", async () => { const { result } = await editReadFile("noexact.ts", " const value = 1;\n", { old_string: "const value = 1;", new_string: "x", replace_all: true, }); expect(result.isError).toBe(true); expect(result.content).toContain("No replacement was performed: old_string was not found in"); }); }); describe("edit tool — guards and failure prose", () => { it("rejects ambiguous matches, citing line numbers", async () => { const { abs, result, disk } = await editReadFile( "ambig.ts", "start\nreturn value;\nmiddle\nreturn value;\nend\n", { old_string: "return value;", new_string: "return other;" }, ); expect(result.isError).toBe(true); expect(result.content).toBe( `No replacement was performed: old_string matches 2 locations in ${abs} (lines 2, 4). ` + "Add more surrounding lines to old_string so it uniquely identifies one location, or set replace_all to true to change all 2.", ); expect(disk()).toContain("start\nreturn value;"); }); it("reports a whitespace near-miss with cited lines and quoted text", async () => { const { abs, result } = await editReadFile( "nearmiss.ts", " const a = 1;\nconst b = 2;\n", { old_string: "\tconst a = 1;\nconst b = 99;", new_string: "whatever" }, ); expect(result.isError).toBe(true); expect(result.content).toContain( `No replacement was performed: old_string was not found in ${abs}.`, ); expect(result.content).toContain("Closest near-miss is at lines 1–2"); expect(result.content).toContain("differs in whitespace on line 1"); expect(result.content).toContain('expected "\\tconst a = 1;", file has " const a = 1;"'); expect(result.content).toContain("provide old_string exactly as it appears in the file"); }); it("refuses disproportionate fuzzy matches", async () => { const bigComment = "// " + "z".repeat(1100); const { result } = await editReadFile( "guard.ts", `function omega() {\n const w = 1;\n return w; ${bigComment}\n}\n`, { old_string: "function omega() {\n const w = 1;\n return w;\n}", new_string: "function omega() {\n return 1;\n}", }, ); expect(result.isError).toBe(true); expect(result.content).toContain("disproportionately larger than the text you provided"); expect(result.content).toContain("Re-read the file and provide the full exact text"); }); it("plain not-found when nothing resembles old_string", async () => { const { abs, result } = await editReadFile("plain.ts", "const a = 1;\n", { old_string: "zzz qqq totally absent", new_string: "x", }); expect(result.isError).toBe(true); expect(result.content).toBe( `No replacement was performed: old_string was not found in ${abs}. ` + "Read the file and provide old_string exactly as it appears, including whitespace and indentation.", ); }); it("rejects identical old_string and new_string", async () => { const { result } = await editReadFile("same.ts", "const a = 1;\n", { old_string: "const a = 1;", new_string: "const a = 1;", }); expect(result.isError).toBe(true); expect(result.content).toBe( "old_string and new_string are identical — there is nothing to change.", ); }); it("rejects an empty old_string, pointing at write", async () => { const { result } = await editReadFile("emptyold.ts", "const a = 1;\n", { old_string: "", new_string: "x", }); expect(result.isError).toBe(true); expect(result.content).toContain("old_string is empty"); expect(result.content).toContain("write tool"); }); it("enforces read-before-edit", async () => { const abs = fixture(h, "unread.ts", "const a = 1;\n"); const result = await edit.execute( { file_path: "unread.ts", old_string: "const a = 1;", new_string: "const a = 2;" }, h.ctx, ); expect(result.isError).toBe(true); expect(result.content).toBe( `Refusing to edit ${abs}: you have not read this file in this session. Read it first so you do not destroy existing content, then write or edit it.`, ); }); it("detects external modification since the read", async () => { const abs = fixture(h, "stale.ts", "const a = 1;\n"); await markRead(h, abs); writeFileSync(abs, "const a = 1; // user touched this\n"); const result = await edit.execute( { file_path: "stale.ts", old_string: "const a = 1;", new_string: "const a = 2;" }, h.ctx, ); expect(result.isError).toBe(true); expect(result.content).toBe( `Refusing to edit ${abs}: the file changed on disk after you last read it (content hash mismatch). Someone else may be editing it. Re-read the file and reapply your change.`, ); }); it("reports missing files", async () => { const result = await edit.execute( { file_path: "ghost.ts", old_string: "a", new_string: "b" }, h.ctx, ); expect(result.isError).toBe(true); expect(result.content).toContain("File not found:"); }); }); describe("edit tool — write-back fidelity and output", () => { it("preserves CRLF line endings", async () => { const { disk } = await editReadFile("crlf.ts", "a\r\nb\r\nc\r\n", { old_string: "b", new_string: "B", }); expect(disk()).toBe("a\r\nB\r\nc\r\n"); }); it("preserves a BOM", async () => { const { disk, result } = await editReadFile("bom.ts", "\uFEFFconst x = 1;\n", { old_string: "x = 1", new_string: "x = 2", }); expect(result.isError).toBeUndefined(); expect(disk()).toBe("\uFEFFconst x = 2;\n"); }); it("returns a line-numbered snippet of the edited region and a review nudge", async () => { const body = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n"); const { result } = await editReadFile("snippet.ts", `${body}\n`, { old_string: "line 10", new_string: "line ten", }); expect(result.content).toContain("Snippet of the edited region (lines 6–14):"); expect(result.content).toContain(" 10→line ten"); expect(result.content).toContain(" 6→line 6"); expect(result.content).toContain(" 14→line 14"); expect(result.content).toContain( "Review the changes. Edit the file again if the result is not what you intended.", ); }); it("attaches a unified diff with counts and emits file.modified", async () => { const { result } = await editReadFile("meta.ts", "keep\nold line\nkeep2\n", { old_string: "old line", new_string: "new line", }); expect(result.metadata?.additions).toBe(1); expect(result.metadata?.deletions).toBe(1); expect(result.metadata?.diff).toContain("-old line"); expect(result.metadata?.diff).toContain("+new line"); expect(result.metadata?.title).toBe("Edit meta.ts · +1 −1"); const event = h.events.find((e) => e.type === "file.modified"); expect(event).toBeDefined(); if (event?.type === "file.modified") { expect(event.payload.operation).toBe("edit"); expect(event.payload.diffStats).toEqual({ added: 1, removed: 1 }); } }); it("serializes concurrent edits to the same file", async () => { const abs = fixture(h, "mutex.ts", "one\ntwo\nthree\n"); await markRead(h, abs); const [r1, r2] = await Promise.all([ edit.execute({ file_path: "mutex.ts", old_string: "one", new_string: "ONE" }, h.ctx), edit.execute({ file_path: "mutex.ts", old_string: "three", new_string: "THREE" }, h.ctx), ]); // The second edit ran against the first edit's result — the external-modification // guard sees the agent's own stamped write, so both succeed cleanly in order. expect(r1.isError).toBeUndefined(); expect(r2.isError).toBeUndefined(); expect(readFileSync(abs, "utf8")).toBe("ONE\ntwo\nTHREE\n"); }); });