Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
29 kB · 837 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";import { tmpdir } from "os";import { join } from "path";import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";import { executeBashWithOperations } from "../src/core/bash-executor.js";import type { ExtensionContext } from "../src/core/extensions/types.js";import { type BashOperations, createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";import { createEditToolDefinition } from "../src/core/tools/edit.js";import { computeEditsDiff } from "../src/core/tools/edit-diff.js";import { createEditTool } from "../src/index.js";import * as shellModule from "../src/utils/shell.js";
const editTool = createEditTool(process.cwd());const bashTool = createBashTool(process.cwd());
// Helper to extract text from content blocksfunction getTextOutput(result: any): string { return ( result.content ?.filter((c: any) => c.type === "text") .map((c: any) => c.text) .join("\n") || "" );}
describe("Coding Agent Tools", () => { let testDir: string;
beforeEach(() => { // Create a unique temporary directory for each test testDir = join(tmpdir(), `coding-agent-test-${Date.now()}`); mkdirSync(testDir, { recursive: true }); });
afterEach(() => { // Clean up test directory rmSync(testDir, { recursive: true, force: true }); });
describe("edit tool", () => { it("should replace text in file", async () => { const testFile = join(testDir, "edit-test.txt"); const originalContent = "Hello, world!"; writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-call-5", { path: testFile, edits: [{ oldText: "world", newText: "testing" }], });
expect(getTextOutput(result)).toContain("Successfully replaced"); expect(result.details).toBeDefined(); expect(result.details.diff).toBeDefined(); expect(typeof result.details.diff).toBe("string"); expect(result.details.diff).toContain("testing"); });
it("should fail if text not found", async () => { const testFile = join(testDir, "edit-test.txt"); const originalContent = "Hello, world!"; writeFileSync(testFile, originalContent);
await expect( editTool.execute("test-call-6", { path: testFile, edits: [{ oldText: "nonexistent", newText: "testing" }], }), ).rejects.toThrow(/Could not find the exact text/); });
it("should include ENOENT when the edit target does not exist", async () => { const missingFile = join(testDir, "missing.txt");
await expect( editTool.execute("test-call-6b", { path: missingFile, edits: [{ oldText: "hello", newText: "world" }], }), ).rejects.toThrow(`Could not edit file: ${missingFile}. Error code: ENOENT.`); });
it("should fail if text appears multiple times", async () => { const testFile = join(testDir, "edit-test.txt"); const originalContent = "foo foo foo"; writeFileSync(testFile, originalContent);
await expect( editTool.execute("test-call-7", { path: testFile, edits: [{ oldText: "foo", newText: "bar" }], }), ).rejects.toThrow(/Found 3 occurrences/); });
it("should replace multiple disjoint regions in one call", async () => { const testFile = join(testDir, "edit-multi.txt"); writeFileSync(testFile, "alpha\nbeta\ngamma\ndelta\n");
const result = await editTool.execute("test-call-8", { path: testFile, edits: [ { oldText: "alpha\n", newText: "ALPHA\n" }, { oldText: "gamma\n", newText: "GAMMA\n" }, ], });
expect(getTextOutput(result)).toContain("Successfully replaced 2 block(s)"); expect(readFileSync(testFile, "utf-8")).toBe("ALPHA\nbeta\nGAMMA\ndelta\n"); expect(result.details?.diff).toContain("ALPHA"); expect(result.details?.diff).toContain("GAMMA"); });
it("should collapse large unchanged gaps in multi-edit diffs", async () => { const testFile = join(testDir, "edit-multi-large-gap.txt"); const lines = Array.from({ length: 600 }, (_, i) => `line ${String(i + 1).padStart(3, "0")}`); writeFileSync(testFile, `${lines.join("\n")}\n`);
const result = await editTool.execute("test-call-8b", { path: testFile, edits: [ { oldText: "line 100\n", newText: "LINE 100\n" }, { oldText: "line 300\n", newText: "LINE 300\n" }, { oldText: "line 500\n", newText: "LINE 500\n" }, ], });
const diff = result.details?.diff ?? ""; expect(diff).toContain("LINE 100"); expect(diff).toContain("LINE 300"); expect(diff).toContain("LINE 500"); expect(diff).toContain("..."); expect(diff).not.toContain("line 250"); expect(diff.split("\n").length).toBeLessThan(50); });
it("should match edits against the original file, not incrementally", async () => { const testFile = join(testDir, "edit-multi-original.txt"); writeFileSync(testFile, "foo\nbar\nbaz\n");
await editTool.execute("test-call-9", { path: testFile, edits: [ { oldText: "foo\n", newText: "foo bar\n" }, { oldText: "bar\n", newText: "BAR\n" }, ], });
expect(readFileSync(testFile, "utf-8")).toBe("foo bar\nBAR\nbaz\n"); });
it("should fail when edits is empty", async () => { const testFile = join(testDir, "edit-empty-edits.txt"); writeFileSync(testFile, "hello\nworld\n");
await expect( editTool.execute("test-call-11", { path: testFile, edits: [], }), ).rejects.toThrow(/edits must contain at least one replacement/); });
it("should fail when multi-edit regions overlap", async () => { const testFile = join(testDir, "edit-overlap.txt"); writeFileSync(testFile, "one\ntwo\nthree\n");
await expect( editTool.execute("test-call-12", { path: testFile, edits: [ { oldText: "one\ntwo\n", newText: "ONE\nTWO\n" }, { oldText: "two\nthree\n", newText: "TWO\nTHREE\n" }, ], }), ).rejects.toThrow(/overlap/); });
it("should not partially apply edits when one edit fails", async () => { const testFile = join(testDir, "edit-no-partial.txt"); const originalContent = "alpha\nbeta\ngamma\n"; writeFileSync(testFile, originalContent);
await expect( editTool.execute("test-call-13", { path: testFile, edits: [ { oldText: "alpha\n", newText: "ALPHA\n" }, { oldText: "missing\n", newText: "MISSING\n" }, ], }), ).rejects.toThrow(/Could not find/);
expect(readFileSync(testFile, "utf-8")).toBe(originalContent); });
it("should include EACCES for read-only files", async () => { const testFile = join(testDir, "edit-readonly.txt"); writeFileSync(testFile, "hello\n"); chmodSync(testFile, 0o444);
await expect( editTool.execute("test-call-14", { path: testFile, edits: [{ oldText: "hello", newText: "world" }], }), ).rejects.toThrow(`Could not edit file: ${testFile}. Error code: EACCES.`); });
it("should include the original error message for unknown edit access errors", async () => { const genericFailureTool = createEditTool(testDir, { operations: { access: async () => { throw new Error("disk offline"); }, readFile: async () => Buffer.from("hello\n", "utf-8"), writeFile: async () => {}, }, });
await expect( genericFailureTool.execute("test-call-16", { path: "broken.txt", edits: [{ oldText: "hello", newText: "world" }], }), ).rejects.toThrow("Could not edit file: broken.txt. Error: disk offline."); });
it("should include ENOENT in diff preview for missing files", async () => { const missingFile = join(testDir, "missing-preview.txt"); const result = await computeEditsDiff(missingFile, [{ oldText: "hello", newText: "world" }], testDir);
expect(result).toEqual({ error: `Could not edit file: ${missingFile}. Error code: ENOENT.` }); });
it("should include EACCES in diff preview for unreadable files", async () => { const unreadableFile = join(testDir, "unreadable-preview.txt"); writeFileSync(unreadableFile, "hello\n"); chmodSync(unreadableFile, 0o222);
const result = await computeEditsDiff(unreadableFile, [{ oldText: "hello", newText: "world" }], testDir);
expect(result).toEqual({ error: `Could not edit file: ${unreadableFile}. Error code: EACCES.` }); }); });
describe("bash tool", () => { it("should execute simple commands", async () => { const result = await bashTool.execute("test-call-8", { command: "echo 'test output'" });
expect(getTextOutput(result)).toContain("test output"); expect(result.details).toBeUndefined(); });
it("should handle command errors", async () => { await expect(bashTool.execute("test-call-9", { command: "exit 1" })).rejects.toThrow( /(Command failed|code 1)/, ); });
it("should respect timeout", async () => { await expect(bashTool.execute("test-call-10", { command: "sleep 5", timeout: 1 })).rejects.toThrow( /timed out/i, ); });
it("should include full output path for truncated timeout and abort errors", async () => { for (const testCase of [ { error: "timeout:5", expected: "Command timed out after 5 seconds" }, { error: "aborted", expected: "Command aborted" }, ]) { const operations: BashOperations = { exec: async (_command, _cwd, { onData }) => { for (let i = 1; i <= 3000; i++) { onData(Buffer.from(`${i}\n`, "utf-8")); } throw new Error(testCase.error); }, }; const bash = createBashTool(testDir, { operations });
let error: unknown; try { await bash.execute(`test-call-${testCase.error}`, { command: "chatty-fail" }); } catch (err) { error = err; }
expect(error).toBeInstanceOf(Error); const message = (error as Error).message; expect(message).toContain(testCase.expected); expect(message).toMatch(/\[Showing lines \d+-\d+ of \d+\. Full output: /); expect(message).not.toContain("Full output: undefined"); const fullOutputPath = message.match(/Full output: ([^\]\n]+)/)?.[1]; expect(fullOutputPath).toBeDefined(); expect(existsSync(fullOutputPath!)).toBe(true); const fullOutput = readFileSync(fullOutputPath!, "utf-8"); expect(fullOutput).toContain("1\n2\n3"); expect(fullOutput).toContain("2998\n2999\n3000"); } });
it("should throw error when cwd does not exist", async () => { const nonexistentCwd = "/this/directory/definitely/does/not/exist/12345";
const bashToolWithBadCwd = createBashTool(nonexistentCwd);
await expect(bashToolWithBadCwd.execute("test-call-11", { command: "echo test" })).rejects.toThrow( /Working directory does not exist/, ); });
it("should handle process spawn errors", async () => { vi.spyOn(shellModule, "getShellConfig").mockReturnValueOnce({ shell: "/nonexistent-shell-path-xyz123", args: ["-c"], });
const bashWithBadShell = createBashTool(testDir);
await expect(bashWithBadShell.execute("test-call-12", { command: "echo test" })).rejects.toThrow(/ENOENT/); });
it("should pass shellPath through to shell resolution", async () => { // vi.spyOn returns the spy created by the previous test, including its // recorded calls; clear them so the assertions only see this test's calls. const getShellConfigSpy = vi.spyOn(shellModule, "getShellConfig"); getShellConfigSpy.mockClear(); const bashWithCustomShell = createBashTool(testDir, { shellPath: "/custom/bash", operations: { exec: async () => ({ exitCode: 0 }), }, });
await bashWithCustomShell.execute("test-call-12b", { command: "echo test" });
expect(getShellConfigSpy).not.toHaveBeenCalled();
const ops = createLocalBashOperations({ shellPath: "/custom/bash" }); await expect( ops.exec("echo test", testDir, { onData: () => {}, }), ).rejects.toThrow("Custom shell path not found: /custom/bash"); expect(getShellConfigSpy).toHaveBeenCalledWith("/custom/bash"); });
it("should prepend command prefix when configured", async () => { const bashWithPrefix = createBashTool(testDir, { commandPrefix: "export TEST_VAR=hello", });
const result = await bashWithPrefix.execute("test-prefix-1", { command: "echo $TEST_VAR" }); expect(getTextOutput(result).trim()).toBe("hello"); });
it("should include output from both prefix and command", async () => { const bashWithPrefix = createBashTool(testDir, { commandPrefix: "echo prefix-output", });
const result = await bashWithPrefix.execute("test-prefix-2", { command: "echo command-output" }); expect(getTextOutput(result).trim()).toBe("prefix-output\ncommand-output"); });
it("should work without command prefix", async () => { const bashWithoutPrefix = createBashTool(testDir, {});
const result = await bashWithoutPrefix.execute("test-prefix-3", { command: "echo no-prefix" }); expect(getTextOutput(result).trim()).toBe("no-prefix"); });
it("should coalesce streaming updates for chatty output", async () => { const operations: BashOperations = { exec: async (_command, _cwd, { onData }) => { for (let i = 0; i < 5000; i++) { onData(Buffer.from(`line ${i}\n`, "utf-8")); } return { exitCode: 0 }; }, }; const updates: Array<{ content: Array<{ type: string; text?: string }>; details?: unknown }> = []; const bash = createBashTool(testDir, { operations });
const result = await bash.execute("test-call-chatty-updates", { command: "chatty" }, undefined, (update) => updates.push(update), );
expect(updates.length).toBeLessThan(25); expect(getTextOutput(result)).toContain("line 4999"); });
it("should decode UTF-8 characters split across output chunks", async () => { const euro = Buffer.from("€\n", "utf-8"); const operations: BashOperations = { exec: async (_command, _cwd, { onData }) => { onData(euro.subarray(0, 1)); onData(euro.subarray(1)); return { exitCode: 0 }; }, }; const bash = createBashTool(testDir, { operations });
const result = await bash.execute("test-call-split-utf8", { command: "split-utf8" });
expect(getTextOutput(result).trim()).toBe("€"); });
it("should expose local bash operations for extension reuse", async () => { const ops = createLocalBashOperations(); const chunks: Buffer[] = [];
const result = await ops.exec("echo $TEST_LOCAL_BASH_OPS", testDir, { onData: (data) => chunks.push(data), env: { ...process.env, TEST_LOCAL_BASH_OPS: "from-local-ops" }, });
expect(result.exitCode).toBe(0); expect(Buffer.concat(chunks).toString("utf-8").trim()).toBe("from-local-ops"); });
it("should preserve executeBash sanitization when using local bash operations", async () => { const result = await executeBashWithOperations( "printf '\\033[31mred\\033[0m\\r\\n'", process.cwd(), createLocalBashOperations(), );
expect(result.exitCode).toBe(0); expect(result.output).toBe("red\n"); });
it("omits the full-output pointer and still succeeds when the spill degrades", async () => { const realTmp = process.env.TMPDIR; process.env.TMPDIR = join(testDir, "no-such-tmp"); try { const operations: BashOperations = { exec: async (_command, _cwd, { onData }) => { for (let i = 1; i <= 3000; i++) { onData(Buffer.from(`${i}\n`, "utf-8")); } return { exitCode: 0 }; }, }; const bash = createBashTool(testDir, { operations }); const result = await bash.execute("test-call-degraded-spill", { command: "chatty" }); const output = getTextOutput(result);
expect(result.details?.truncation?.truncated).toBe(true); expect(result.details?.fullOutputPath).toBeUndefined(); expect(output).not.toContain("Full output:"); expect(output).toMatch(/\[Showing lines \d+-\d+ of \d+\]/); expect(output).toContain("3000"); } finally { if (realTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = realTmp; } });
it("should persist full output when truncation happens by line count only", async () => { const bash = createBashTool(testDir); const result = await bash.execute("test-call-line-truncation", { command: "seq 3000" }); const output = getTextOutput(result); const fullOutputPath = result.details?.fullOutputPath;
expect(result.details?.truncation?.truncated).toBe(true); expect(result.details?.truncation?.truncatedBy).toBe("lines"); expect(fullOutputPath).toBeDefined(); expect(output).toMatch(/\[Showing lines \d+-\d+ of \d+\. Full output: /); expect(output).not.toContain("Full output: undefined");
for (let i = 0; i < 20 && (!fullOutputPath || !existsSync(fullOutputPath)); i++) { await new Promise((resolve) => setTimeout(resolve, 10)); }
expect(fullOutputPath).toBeDefined(); expect(existsSync(fullOutputPath!)).toBe(true); const fullOutput = readFileSync(fullOutputPath!, "utf-8"); expect(fullOutput).toContain("1\n2\n3"); expect(fullOutput).toContain("2998\n2999\n3000"); });
it("executeBash should persist full output when truncation happens by line count only", async () => { const result = await executeBashWithOperations("seq 3000", process.cwd(), createLocalBashOperations()); const fullOutputPath = result.fullOutputPath;
expect(result.truncated).toBe(true); expect(fullOutputPath).toBeDefined();
for (let i = 0; i < 20 && (!fullOutputPath || !existsSync(fullOutputPath)); i++) { await new Promise((resolve) => setTimeout(resolve, 10)); }
expect(fullOutputPath).toBeDefined(); expect(existsSync(fullOutputPath!)).toBe(true); const fullOutput = readFileSync(fullOutputPath!, "utf-8"); expect(fullOutput).toContain("1\n2\n3"); expect(fullOutput).toContain("2998\n2999\n3000"); }); });});
describe("edit tool fuzzy matching", () => { let testDir: string;
beforeEach(() => { testDir = join(tmpdir(), `coding-agent-fuzzy-test-${Date.now()}`); mkdirSync(testDir, { recursive: true }); });
afterEach(() => { rmSync(testDir, { recursive: true, force: true }); });
it("should match text with trailing whitespace stripped", async () => { const testFile = join(testDir, "trailing-ws.txt"); // File has trailing spaces on lines writeFileSync(testFile, "line one \nline two \nline three\n");
// oldText without trailing whitespace should still match const result = await editTool.execute("test-fuzzy-1", { path: testFile, edits: [{ oldText: "line one\nline two\n", newText: "replaced\n" }], });
expect(getTextOutput(result)).toContain("Successfully replaced"); const content = readFileSync(testFile, "utf-8"); expect(content).toBe("replaced\nline three\n"); });
it("should match fullwidth punctuation in Chinese text", async () => { const testFile = join(testDir, "chinese-punctuation.txt"); writeFileSync(testFile, "你好,世界\n你好(世界)\n");
const result = await editTool.execute("test-fuzzy-chinese", { path: testFile, edits: [{ oldText: "你好,世界\n你好(世界)\n", newText: "你好,pi\n你好(pi)\n" }], });
expect(getTextOutput(result)).toContain("Successfully replaced"); const content = readFileSync(testFile, "utf-8"); expect(content).toBe("你好,pi\n你好(pi)\n"); });
it("should match compatibility-equivalent Unicode forms", async () => { const testFile = join(testDir, "unicode-compatibility.txt"); writeFileSync(testFile, "ABC123\ncafe\u0301\n");
const result = await editTool.execute("test-fuzzy-unicode", { path: testFile, edits: [{ oldText: "ABC123\ncafé\n", newText: "XYZ789\ncoffee\n" }], });
expect(getTextOutput(result)).toContain("Successfully replaced"); const content = readFileSync(testFile, "utf-8"); expect(content).toBe("XYZ789\ncoffee\n"); });
it("should match smart single quotes to ASCII quotes", async () => { const testFile = join(testDir, "smart-quotes.txt"); // File has smart/curly single quotes (U+2018, U+2019) writeFileSync(testFile, "console.log(\u2018hello\u2019);\n");
// oldText with ASCII quotes should match const result = await editTool.execute("test-fuzzy-2", { path: testFile, edits: [{ oldText: "console.log('hello');", newText: "console.log('world');" }], });
expect(getTextOutput(result)).toContain("Successfully replaced"); const content = readFileSync(testFile, "utf-8"); expect(content).toContain("world"); });
it("should match smart double quotes to ASCII quotes", async () => { const testFile = join(testDir, "smart-double-quotes.txt"); // File has smart/curly double quotes (U+201C, U+201D) writeFileSync(testFile, "const msg = \u201CHello World\u201D;\n");
// oldText with ASCII quotes should match const result = await editTool.execute("test-fuzzy-3", { path: testFile, edits: [{ oldText: 'const msg = "Hello World";', newText: 'const msg = "Goodbye";' }], });
expect(getTextOutput(result)).toContain("Successfully replaced"); const content = readFileSync(testFile, "utf-8"); expect(content).toContain("Goodbye"); });
it("should match Unicode dashes to ASCII hyphen", async () => { const testFile = join(testDir, "unicode-dashes.txt"); // File has en-dash (U+2013) and em-dash (U+2014) writeFileSync(testFile, "range: 1\u20135\nbreak\u2014here\n");
// oldText with ASCII hyphens should match const result = await editTool.execute("test-fuzzy-4", { path: testFile, edits: [{ oldText: "range: 1-5\nbreak-here", newText: "range: 10-50\nbreak--here" }], });
expect(getTextOutput(result)).toContain("Successfully replaced"); const content = readFileSync(testFile, "utf-8"); expect(content).toContain("10-50"); });
it("should match non-breaking space to regular space", async () => { const testFile = join(testDir, "nbsp.txt"); // File has non-breaking space (U+00A0) writeFileSync(testFile, "hello\u00A0world\n");
// oldText with regular space should match const result = await editTool.execute("test-fuzzy-5", { path: testFile, edits: [{ oldText: "hello world", newText: "hello universe" }], });
expect(getTextOutput(result)).toContain("Successfully replaced"); const content = readFileSync(testFile, "utf-8"); expect(content).toContain("universe"); });
it("should prefer exact match over fuzzy match", async () => { const testFile = join(testDir, "exact-preferred.txt"); // File has both exact and fuzzy-matchable content writeFileSync(testFile, "const x = 'exact';\nconst y = 'other';\n");
const result = await editTool.execute("test-fuzzy-6", { path: testFile, edits: [{ oldText: "const x = 'exact';", newText: "const x = 'changed';" }], });
expect(getTextOutput(result)).toContain("Successfully replaced"); const content = readFileSync(testFile, "utf-8"); expect(content).toBe("const x = 'changed';\nconst y = 'other';\n"); });
it("should still fail when text is not found even with fuzzy matching", async () => { const testFile = join(testDir, "no-match.txt"); writeFileSync(testFile, "completely different content\n");
await expect( editTool.execute("test-fuzzy-7", { path: testFile, edits: [{ oldText: "this does not exist", newText: "replacement" }], }), ).rejects.toThrow(/Could not find the exact text/); });
it("should detect duplicates after fuzzy normalization", async () => { const testFile = join(testDir, "fuzzy-dups.txt"); // Two lines that are identical after trailing whitespace is stripped writeFileSync(testFile, "hello world \nhello world\n");
await expect( editTool.execute("test-fuzzy-8", { path: testFile, edits: [{ oldText: "hello world", newText: "replaced" }], }), ).rejects.toThrow(/Found 2 occurrences/); });
it("should support fuzzy matching in multi-edit mode", async () => { const testFile = join(testDir, "fuzzy-multi.txt"); writeFileSync(testFile, "console.log(\u2018hello\u2019);\nhello\u00A0world\n");
await editTool.execute("test-fuzzy-9", { path: testFile, edits: [ { oldText: "console.log('hello');\n", newText: "console.log('world');\n" }, { oldText: "hello world\n", newText: "hello universe\n" }, ], });
expect(readFileSync(testFile, "utf-8")).toBe("console.log('world');\nhello universe\n"); });});
describe("edit tool CRLF handling", () => { let testDir: string;
beforeEach(() => { testDir = join(tmpdir(), `coding-agent-crlf-test-${Date.now()}`); mkdirSync(testDir, { recursive: true }); });
afterEach(() => { rmSync(testDir, { recursive: true, force: true }); });
it("should match LF oldText against CRLF file content", async () => { const testFile = join(testDir, "crlf-test.txt");
writeFileSync(testFile, "line one\r\nline two\r\nline three\r\n");
const result = await editTool.execute("test-crlf-1", { path: testFile, edits: [{ oldText: "line two\n", newText: "replaced line\n" }], });
expect(getTextOutput(result)).toContain("Successfully replaced"); });
it("should preserve CRLF line endings after edit", async () => { const testFile = join(testDir, "crlf-preserve.txt"); writeFileSync(testFile, "first\r\nsecond\r\nthird\r\n");
await editTool.execute("test-crlf-2", { path: testFile, edits: [{ oldText: "second\n", newText: "REPLACED\n" }], });
const content = readFileSync(testFile, "utf-8"); expect(content).toBe("first\r\nREPLACED\r\nthird\r\n"); });
it("should preserve LF line endings for LF files", async () => { const testFile = join(testDir, "lf-preserve.txt"); writeFileSync(testFile, "first\nsecond\nthird\n");
await editTool.execute("test-lf-1", { path: testFile, edits: [{ oldText: "second\n", newText: "REPLACED\n" }], });
const content = readFileSync(testFile, "utf-8"); expect(content).toBe("first\nREPLACED\nthird\n"); });
it("should detect duplicates across CRLF/LF variants", async () => { const testFile = join(testDir, "mixed-endings.txt");
writeFileSync(testFile, "hello\r\nworld\r\n---\r\nhello\nworld\n");
await expect( editTool.execute("test-crlf-dup", { path: testFile, edits: [{ oldText: "hello\nworld\n", newText: "replaced\n" }], }), ).rejects.toThrow(/Found 2 occurrences/); });
it("should preserve UTF-8 BOM after edit", async () => { const testFile = join(testDir, "bom-test.txt"); writeFileSync(testFile, "\uFEFFfirst\r\nsecond\r\nthird\r\n");
await editTool.execute("test-bom", { path: testFile, edits: [{ oldText: "second\n", newText: "REPLACED\n" }], });
const content = readFileSync(testFile, "utf-8"); expect(content).toBe("\uFEFFfirst\r\nREPLACED\r\nthird\r\n"); });
it("should preserve CRLF line endings and BOM in multi-edit mode", async () => { const testFile = join(testDir, "bom-crlf-multi.txt"); writeFileSync(testFile, "\uFEFFfirst\r\nsecond\r\nthird\r\nfourth\r\n");
await editTool.execute("test-crlf-multi", { path: testFile, edits: [ { oldText: "second\n", newText: "SECOND\n" }, { oldText: "fourth\n", newText: "FOURTH\n" }, ], });
const content = readFileSync(testFile, "utf-8"); expect(content).toBe("\uFEFFfirst\r\nSECOND\r\nthird\r\nFOURTH\r\n"); });});describe("edit tool prepareArguments", () => { const definition = createEditToolDefinition(process.cwd()); const prepare = definition.prepareArguments!;
it("keeps legacy fields out of the public schema", () => { expect(definition.parameters.properties).not.toHaveProperty("oldText"); expect(definition.parameters.properties).not.toHaveProperty("newText"); });
it.each<[string, unknown, unknown]>([ [ "folds top-level oldText/newText into edits", { path: "file.txt", oldText: "before", newText: "after" }, { path: "file.txt", edits: [{ oldText: "before", newText: "after" }] }, ], [ "appends a legacy replacement to existing edits", { path: "file.txt", edits: [{ oldText: "a", newText: "b" }], oldText: "c", newText: "d" }, { path: "file.txt", edits: [ { oldText: "a", newText: "b" }, { oldText: "c", newText: "d" }, ], }, ], [ "parses edits from a JSON string", { path: "file.txt", edits: JSON.stringify([{ oldText: "a", newText: "b" }]) }, { path: "file.txt", edits: [{ oldText: "a", newText: "b" }] }, ], [ "leaves edits alone when the string is not valid JSON", { path: "file.txt", edits: "not json" }, { path: "file.txt", edits: "not json" }, ], ])("%s", (_label, input, expected) => { expect(prepare(input)).toEqual(expected); });
it.each<[string, unknown]>([ ["valid input", { path: "file.txt", edits: [{ oldText: "a", newText: "b" }] }], ["null", null], ["undefined", undefined], ["a non-object", "garbage"], ])("passes through %s unchanged", (_label, input) => { expect(prepare(input)).toBe(input); });
it("produces arguments the edit tool can execute", async () => { const dir = join(tmpdir(), `coding-agent-legacy-edit-${Date.now()}`); mkdirSync(dir, { recursive: true }); try { const filePath = join(dir, "legacy.txt"); writeFileSync(filePath, "before\n", "utf8"); const scoped = createEditToolDefinition(dir);
const prepared = scoped.prepareArguments!({ path: "legacy.txt", oldText: "before", newText: "after" }); await scoped.execute("tool-1", prepared, undefined, undefined, {} as ExtensionContext);
expect(readFileSync(filePath, "utf8")).toBe("after\n"); } finally { rmSync(dir, { recursive: true, force: true }); } });});