spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: tests/tools/edit.test.ts4 * Description: Unit tests for the edit tool — one fixture per replacer strategy, plus guards and failure prose.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { readFileSync, rmSync, writeFileSync } from "node:fs";11import { afterEach, beforeEach, describe, expect, it } from "vitest";12import { createEditTool } from "../../src/tools/index.js";13import type { EditInput } from "../../src/tools/index.js";14import type { TestHarness } from "./helpers.js";15import { fixture, makeHarness, markRead } from "./helpers.js";1617let h: TestHarness;18const edit = createEditTool();1920beforeEach(() => {21 h = makeHarness();22});2324afterEach(() => {25 rmSync(h.dir, { recursive: true, force: true });26});2728async function editReadFile(rel: string, content: string, input: Omit<EditInput, "file_path">) {29 const abs = fixture(h, rel, content);30 await markRead(h, abs);31 const result = await edit.execute({ file_path: rel, ...input }, h.ctx);32 return { abs, result, disk: () => readFileSync(abs, "utf8") };33}3435describe("edit tool — replacer cascade", () => {36 it("strategy 1: exact match", async () => {37 const { result, disk } = await editReadFile(38 "exact.ts",39 "function alpha() {\n const value = 1;\n return value;\n}\n",40 { old_string: "const value = 1;", new_string: "const value = 42;" },41 );42 expect(result.isError).toBeUndefined();43 expect(result.content).toContain("(1 replacement, matched exactly).");44 expect(disk()).toContain("const value = 42;");45 expect(result.metadata?.extra?.["strategy"]).toBe("exact");46 });4748 it("strategy 2: line-trimmed (trailing-whitespace drift)", async () => {49 const { result, disk } = await editReadFile(50 "trimmed.ts",51 " const value = 1;\n return value;\n",52 {53 old_string: " const value = 1; \n return value;",54 new_string: " const value = 2;\n return value;",55 },56 );57 expect(result.isError).toBeUndefined();58 expect(result.content).toContain("matched with line-trimmed whitespace");59 expect(disk()).toContain("const value = 2;");60 expect(result.metadata?.extra?.["strategy"]).toBe("line-trimmed");61 });6263 it("strategy 3: whitespace-normalized (single-line interior drift)", async () => {64 const { result, disk } = await editReadFile("wsnorm.ts", " const value = 1;\n", {65 old_string: "const value = 1;",66 new_string: "const value = 42;",67 });68 expect(result.isError).toBeUndefined();69 expect(result.content).toContain("matched with whitespace normalization");70 // The file's own leading indentation is preserved.71 expect(disk()).toBe(" const value = 42;\n");72 expect(result.metadata?.extra?.["strategy"]).toBe("whitespace-normalized");73 });7475 it("strategy 4: indentation-flexible (block quoted at the wrong depth, re-indented)", async () => {76 const { result, disk } = await editReadFile(77 "indent.ts",78 "function run() {\n if (ready) {\n launch();\n }\n}\n",79 {80 old_string: "if (ready) {\n launch();\n}",81 new_string: "if (ready) {\n launchAll();\n}",82 },83 );84 expect(result.isError).toBeUndefined();85 expect(result.content).toContain("matched with indentation flexibility");86 // new_string re-indented to the FILE's depth (4 spaces), preserving relative indent.87 expect(disk()).toBe("function run() {\n if (ready) {\n launchAll();\n }\n}\n");88 expect(result.metadata?.extra?.["strategy"]).toBe("indentation-flexible");89 });9091 it("strategy 5: escape-normalized (over-escaped old_string)", async () => {92 const { result, disk } = await editReadFile("escape.ts", 'console.log("hi");\n', {93 old_string: 'console.log(\\"hi\\");',94 new_string: 'console.log(\\"bye\\");',95 });96 expect(result.isError).toBeUndefined();97 expect(result.content).toContain("matched with escape normalization");98 expect(disk()).toBe('console.log("bye");\n');99 expect(result.metadata?.extra?.["strategy"]).toBe("escape-normalized");100 });101102 it("strategy 6: trimmed-boundary (stray whitespace around old_string)", async () => {103 const { result, disk } = await editReadFile(104 "boundary.ts",105 "// header\nconst value = 1;\nreturn value;\n",106 {107 old_string: " \nconst value = 1;\nreturn value;",108 new_string: " \nconst value = 9;\nreturn value;",109 },110 );111 expect(result.isError).toBeUndefined();112 expect(result.content).toContain("matched at trimmed boundaries");113 expect(disk()).toBe("// header\nconst value = 9;\nreturn value;\n");114 expect(result.metadata?.extra?.["strategy"]).toBe("trimmed-boundary");115 });116117 it("strategy 7: block-anchor (fuzzy middle, exact anchors)", async () => {118 const { result, disk } = await editReadFile(119 "anchor.ts",120 "function gamma() {\n const g = 10; // x\n return g;\n}\n",121 {122 old_string: "function gamma() {\n const g = 10;\n return g;\n}",123 new_string: "function gamma() {\n const g = 20;\n return g;\n}",124 },125 );126 expect(result.isError).toBeUndefined();127 expect(result.content).toContain("matched with block anchors");128 expect(disk()).toContain("const g = 20;");129 expect(result.metadata?.extra?.["strategy"]).toBe("block-anchor");130 });131132 it("strategy 8: context-aware (anchors + ≥50% of middle lines)", async () => {133 const { result, disk } = await editReadFile(134 "context.ts",135 "function delta() {\n const d1 = 1;\n const totallyDifferentLineHereZZZZZZ = 999;\n return d1 + d2;\n}\n",136 {137 old_string: "function delta() {\n const d1 = 1;\n const d2 = 2;\n return d1 + d2;\n}",138 new_string: "function delta() {\n return 3;\n}",139 },140 );141 expect(result.isError).toBeUndefined();142 expect(result.content).toContain("matched with context anchors");143 expect(disk()).toBe("function delta() {\n return 3;\n}\n");144 expect(result.metadata?.extra?.["strategy"]).toBe("context-aware");145 });146147 it("strategy 9: replace_all replaces every exact occurrence and reports the count", async () => {148 const { result, disk } = await editReadFile(149 "rename.ts",150 "foo();\nconst a = foo;\nexport { foo };\n",151 { old_string: "foo", new_string: "bar", replace_all: true },152 );153 expect(result.isError).toBeUndefined();154 expect(result.content).toContain("(3 replacements).");155 expect(disk()).toBe("bar();\nconst a = bar;\nexport { bar };\n");156 expect(result.metadata?.extra?.["strategy"]).toBe("multi-occurrence");157 expect(result.metadata?.extra?.["replacements"]).toBe(3);158 });159160 it("replace_all never falls back to fuzzy matching", async () => {161 const { result } = await editReadFile("noexact.ts", " const value = 1;\n", {162 old_string: "const value = 1;",163 new_string: "x",164 replace_all: true,165 });166 expect(result.isError).toBe(true);167 expect(result.content).toContain("No replacement was performed: old_string was not found in");168 });169});170171describe("edit tool — guards and failure prose", () => {172 it("rejects ambiguous matches, citing line numbers", async () => {173 const { abs, result, disk } = await editReadFile(174 "ambig.ts",175 "start\nreturn value;\nmiddle\nreturn value;\nend\n",176 { old_string: "return value;", new_string: "return other;" },177 );178 expect(result.isError).toBe(true);179 expect(result.content).toBe(180 `No replacement was performed: old_string matches 2 locations in ${abs} (lines 2, 4). ` +181 "Add more surrounding lines to old_string so it uniquely identifies one location, or set replace_all to true to change all 2.",182 );183 expect(disk()).toContain("start\nreturn value;");184 });185186 it("reports a whitespace near-miss with cited lines and quoted text", async () => {187 const { abs, result } = await editReadFile(188 "nearmiss.ts",189 " const a = 1;\nconst b = 2;\n",190 { old_string: "\tconst a = 1;\nconst b = 99;", new_string: "whatever" },191 );192 expect(result.isError).toBe(true);193 expect(result.content).toContain(194 `No replacement was performed: old_string was not found in ${abs}.`,195 );196 expect(result.content).toContain("Closest near-miss is at lines 1–2");197 expect(result.content).toContain("differs in whitespace on line 1");198 expect(result.content).toContain('expected "\\tconst a = 1;", file has " const a = 1;"');199 expect(result.content).toContain("provide old_string exactly as it appears in the file");200 });201202 it("refuses disproportionate fuzzy matches", async () => {203 const bigComment = "// " + "z".repeat(1100);204 const { result } = await editReadFile(205 "guard.ts",206 `function omega() {\n const w = 1;\n return w; ${bigComment}\n}\n`,207 {208 old_string: "function omega() {\n const w = 1;\n return w;\n}",209 new_string: "function omega() {\n return 1;\n}",210 },211 );212 expect(result.isError).toBe(true);213 expect(result.content).toContain("disproportionately larger than the text you provided");214 expect(result.content).toContain("Re-read the file and provide the full exact text");215 });216217 it("plain not-found when nothing resembles old_string", async () => {218 const { abs, result } = await editReadFile("plain.ts", "const a = 1;\n", {219 old_string: "zzz qqq totally absent",220 new_string: "x",221 });222 expect(result.isError).toBe(true);223 expect(result.content).toBe(224 `No replacement was performed: old_string was not found in ${abs}. ` +225 "Read the file and provide old_string exactly as it appears, including whitespace and indentation.",226 );227 });228229 it("rejects identical old_string and new_string", async () => {230 const { result } = await editReadFile("same.ts", "const a = 1;\n", {231 old_string: "const a = 1;",232 new_string: "const a = 1;",233 });234 expect(result.isError).toBe(true);235 expect(result.content).toBe(236 "old_string and new_string are identical — there is nothing to change.",237 );238 });239240 it("rejects an empty old_string, pointing at write", async () => {241 const { result } = await editReadFile("emptyold.ts", "const a = 1;\n", {242 old_string: "",243 new_string: "x",244 });245 expect(result.isError).toBe(true);246 expect(result.content).toContain("old_string is empty");247 expect(result.content).toContain("write tool");248 });249250 it("enforces read-before-edit", async () => {251 const abs = fixture(h, "unread.ts", "const a = 1;\n");252 const result = await edit.execute(253 { file_path: "unread.ts", old_string: "const a = 1;", new_string: "const a = 2;" },254 h.ctx,255 );256 expect(result.isError).toBe(true);257 expect(result.content).toBe(258 `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.`,259 );260 });261262 it("detects external modification since the read", async () => {263 const abs = fixture(h, "stale.ts", "const a = 1;\n");264 await markRead(h, abs);265 writeFileSync(abs, "const a = 1; // user touched this\n");266 const result = await edit.execute(267 { file_path: "stale.ts", old_string: "const a = 1;", new_string: "const a = 2;" },268 h.ctx,269 );270 expect(result.isError).toBe(true);271 expect(result.content).toBe(272 `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.`,273 );274 });275276 it("reports missing files", async () => {277 const result = await edit.execute(278 { file_path: "ghost.ts", old_string: "a", new_string: "b" },279 h.ctx,280 );281 expect(result.isError).toBe(true);282 expect(result.content).toContain("File not found:");283 });284});285286describe("edit tool — write-back fidelity and output", () => {287 it("preserves CRLF line endings", async () => {288 const { disk } = await editReadFile("crlf.ts", "a\r\nb\r\nc\r\n", {289 old_string: "b",290 new_string: "B",291 });292 expect(disk()).toBe("a\r\nB\r\nc\r\n");293 });294295 it("preserves a BOM", async () => {296 const { disk, result } = await editReadFile("bom.ts", "\uFEFFconst x = 1;\n", {297 old_string: "x = 1",298 new_string: "x = 2",299 });300 expect(result.isError).toBeUndefined();301 expect(disk()).toBe("\uFEFFconst x = 2;\n");302 });303304 it("returns a line-numbered snippet of the edited region and a review nudge", async () => {305 const body = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n");306 const { result } = await editReadFile("snippet.ts", `${body}\n`, {307 old_string: "line 10",308 new_string: "line ten",309 });310 expect(result.content).toContain("Snippet of the edited region (lines 6–14):");311 expect(result.content).toContain(" 10→line ten");312 expect(result.content).toContain(" 6→line 6");313 expect(result.content).toContain(" 14→line 14");314 expect(result.content).toContain(315 "Review the changes. Edit the file again if the result is not what you intended.",316 );317 });318319 it("attaches a unified diff with counts and emits file.modified", async () => {320 const { result } = await editReadFile("meta.ts", "keep\nold line\nkeep2\n", {321 old_string: "old line",322 new_string: "new line",323 });324 expect(result.metadata?.additions).toBe(1);325 expect(result.metadata?.deletions).toBe(1);326 expect(result.metadata?.diff).toContain("-old line");327 expect(result.metadata?.diff).toContain("+new line");328 expect(result.metadata?.title).toBe("Edit meta.ts · +1 −1");329 const event = h.events.find((e) => e.type === "file.modified");330 expect(event).toBeDefined();331 if (event?.type === "file.modified") {332 expect(event.payload.operation).toBe("edit");333 expect(event.payload.diffStats).toEqual({ added: 1, removed: 1 });334 }335 });336337 it("serializes concurrent edits to the same file", async () => {338 const abs = fixture(h, "mutex.ts", "one\ntwo\nthree\n");339 await markRead(h, abs);340 const [r1, r2] = await Promise.all([341 edit.execute({ file_path: "mutex.ts", old_string: "one", new_string: "ONE" }, h.ctx),342 edit.execute({ file_path: "mutex.ts", old_string: "three", new_string: "THREE" }, h.ctx),343 ]);344 // The second edit ran against the first edit's result — the external-modification345 // guard sees the agent's own stamped write, so both succeed cleanly in order.346 expect(r1.isError).toBeUndefined();347 expect(r2.isError).toBeUndefined();348 expect(readFileSync(abs, "utf8")).toBe("ONE\ntwo\nTHREE\n");349 });350});351