SPB Git

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%
14.3 KB · 351 lines typescript
Raw Blame History
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