pi-mono/packages/coding-agent/test/tools.test.ts
Petr Houška 8da141c2ad
fix(coding-agent): take grep context lines from ripgrep instead of reading matched files (#11705)
When `context` is set, the grep tool read every matched file whole and
split it into lines, only to print the lines around each match. Memory
grew with the size of the matched files, not the output. In the issue's
reproduction (8 log files of about 95 MB, one match each, `context: 2`,
`--max-old-space-size=1024`), the process ran out of heap after about 10
s.

ripgrep already supports context. This change passes `--context N` to rg
and formats the `context` events from its JSON output. The tool no
longer opens matched files.

Details:
- After the last allowed match (`limit`), rg still sends that match's
trailing context. The tool keeps those lines, then stops rg. A match
inside that window is shown as a context line, as before.
- Lines past the 50 KB output cap are counted but not stored, since
truncation drops them anyway. Match counts, the match-limit notice, and
`details.matchLimitReached` stay as before.
- rg merges overlapping context, so lines shared by nearby matches are
printed once instead of repeated per match. This is the one intended
output change.
- Context lines are decoded by rg, so UTF-16 files with a BOM show
correctly with context (#11423, closed as no-action). Non-UTF-8 lines
and paths, which rg sends base64-encoded, are decoded too.

Two related grep fixes in the same code:
- **Exact match limit:** with exactly N matches and `limit: N`, grep
said "N matches limit reached. Use limit=2N". It now sets the notice and
`details.matchLimitReached` only after rg sends one more match. Cost:
when no further match exists, rg finishes the search instead of being
stopped at the Nth match, the same as a search with fewer than N
matches.
- **Lone CR:** grep removed every `\r` in a line, so `one\rtwo needle`
showed as `onetwo needle` and text copied from grep failed to match in
edit. It now strips only the line terminator (`\n` or `\r\n`), like the
read tool. The TUI still hides CRs when rendering. This updates the
expectation in the #11398 test from `beforeprogress` to
`before\rprogress`.

Breaking: `GrepOperations.readFile` is removed because nothing uses it
anymore. rg always ran locally, so this hook never made grep work on a
remote backend. Docs now say to override `execute()` for remote search,
as the Gondolin example does. Code that passes `readFile` still runs,
and the value is ignored. Only code that type-checks an object literal
against `GrepOperations` fails to compile.

Known remaining differences and limits:
- Memory no longer grows with file size or match count, but a single
huge matching line (tens of MB) is still buffered whole before being cut
to 500 characters. That was already true for `context: 0` on main.
Bounding it would mean discarding oversized rg JSON records, which is a
separate change.
- In a directory search, rg stops reading a file once it finds a NUL
byte, so context past that point in a text-looking file that turns
binary later is omitted. main reread the file and printed it. Explicit
file paths are unaffected.
- `details.truncation.totalLines`/`totalBytes` count formatted rows.
With deduplicated context, or file names containing newlines, they can
differ from main. No renderer or notice displays them.

**Testing**
- Issue reproduction: before, `FATAL ERROR ... JavaScript heap out of
memory`. After, correct output in about 60 ms with 135 MB RSS.
- `pattern: "line", context: 3, limit: 100000` over the same 770 MB of
logs reports both limit notices and peaks at 154 MB RSS.
- New tests in `test/tools.test.ts`:
- Merged overlapping context, plus trailing context at the limit
including a match inside it. Fails on main.
  - UTF-16 with context. Fails on main.
- Truncation reported when stored output ends exactly at 50 KB. Guards
the counting logic.
- Matches still counted past the byte cap. Passes on main too; it guards
against stopping rg early at the cap.
  - Huge `context` value accepted.
- No limit notice with exactly `limit` matches (with and without
context), notice with more. Fails before this change.
- Lone CR kept and CRLF stripped, with and without context. Fails before
this change.
- All 94 tests in the file pass. `npm run check` passes.

fixes #9276

This PR is AI-generated.
2026-11-03 23:43:40 +01:00

1611 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { applyPatch } from "diff";
import { 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.ts";
import type { ExtensionToolContext } from "../src/core/extensions/types.ts";
import {
type BashOperations,
createBashTool,
createBashToolDefinition,
createLocalBashOperations,
} from "../src/core/tools/bash.ts";
import { createEditToolDefinition } from "../src/core/tools/edit.ts";
import { computeEditsDiff } from "../src/core/tools/edit-diff.ts";
import { createFindToolDefinition } from "../src/core/tools/find.ts";
import { createGrepToolDefinition } from "../src/core/tools/grep.ts";
import { createLsToolDefinition } from "../src/core/tools/ls.ts";
import { createReadToolDefinition } from "../src/core/tools/read.ts";
import { createWriteToolDefinition } from "../src/core/tools/write.ts";
import {
createEditTool,
createFindTool,
createGrepTool,
createLsTool,
createReadTool,
createWriteTool,
} from "../src/index.ts";
import * as shellModule from "../src/utils/shell.ts";
const readTool = createReadTool(process.cwd());
const writeTool = createWriteTool(process.cwd());
const editTool = createEditTool(process.cwd());
const bashTool = createBashTool(process.cwd());
const grepTool = createGrepTool(process.cwd());
const findTool = createFindTool(process.cwd());
const lsTool = createLsTool(process.cwd());
// Helper to extract text from content blocks
function getTextOutput(result: any): string {
return (
result.content
?.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || ""
);
}
function createTinyBmp1x1Red24bpp(): Buffer {
const buffer = Buffer.alloc(58);
buffer.write("BM", 0, "ascii");
buffer.writeUInt32LE(buffer.length, 2);
buffer.writeUInt32LE(54, 10);
buffer.writeUInt32LE(40, 14);
buffer.writeInt32LE(1, 18);
buffer.writeInt32LE(1, 22);
buffer.writeUInt16LE(1, 26);
buffer.writeUInt16LE(24, 28);
buffer.writeUInt32LE(0, 30);
buffer.writeUInt32LE(4, 34);
buffer[56] = 0xff;
return buffer;
}
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(() => {
vi.restoreAllMocks();
// Clean up test directory
rmSync(testDir, { recursive: true, force: true });
});
describe("read tool", () => {
it("should read file contents that fit within limits", async () => {
const testFile = join(testDir, "test.txt");
const content = "Hello, world!\nLine 2\nLine 3";
writeFileSync(testFile, content);
const result = await readTool.execute("test-call-1", { path: testFile });
expect(getTextOutput(result)).toBe(content);
// No truncation message since file fits within limits
expect(getTextOutput(result)).not.toContain("Use offset=");
expect(result.details).toBeUndefined();
expect(result.structuredContent).toBe(content);
});
it("should handle non-existent files", async () => {
const testFile = join(testDir, "nonexistent.txt");
await expect(readTool.execute("test-call-2", { path: testFile })).rejects.toThrow(/ENOENT|not found/i);
});
it("should truncate files exceeding line limit", async () => {
const testFile = join(testDir, "large.txt");
const lines = Array.from({ length: 2500 }, (_, i) => `Line ${i + 1}`);
writeFileSync(testFile, lines.join("\n"));
const result = await readTool.execute("test-call-3", { path: testFile });
const output = getTextOutput(result);
expect(output).toContain("Line 1");
expect(output).toContain("Line 2000");
expect(output).not.toContain("Line 2001");
expect(output).toContain("[Showing lines 1-2000 of 2500. Use offset=2001 to continue.]");
});
it("should truncate when byte limit exceeded", async () => {
const testFile = join(testDir, "large-bytes.txt");
// Create file that exceeds 50KB byte limit but has fewer than 2000 lines
const lines = Array.from({ length: 500 }, (_, i) => `Line ${i + 1}: ${"x".repeat(200)}`);
writeFileSync(testFile, lines.join("\n"));
const result = await readTool.execute("test-call-4", { path: testFile });
const output = getTextOutput(result);
expect(output).toContain("Line 1:");
// Should show byte limit message
expect(output).toMatch(/\[Showing lines 1-\d+ of 500 \(.* limit\)\. Use offset=\d+ to continue\.\]/);
});
it("should handle offset parameter", async () => {
const testFile = join(testDir, "offset-test.txt");
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
writeFileSync(testFile, lines.join("\n"));
const result = await readTool.execute("test-call-5", { path: testFile, offset: 51 });
const output = getTextOutput(result);
expect(output).not.toContain("Line 50");
expect(output).toContain("Line 51");
expect(output).toContain("Line 100");
// No truncation message since file fits within limits
expect(output).not.toContain("Use offset=");
});
it("should handle limit parameter", async () => {
const testFile = join(testDir, "limit-test.txt");
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
writeFileSync(testFile, lines.join("\n"));
const result = await readTool.execute("test-call-6", { path: testFile, limit: 10 });
const output = getTextOutput(result);
expect(output).toContain("Line 1");
expect(output).toContain("Line 10");
expect(output).not.toContain("Line 11");
expect(output).toContain("[90 more lines in file. Use offset=11 to continue.]");
});
it("should handle offset + limit together", async () => {
const testFile = join(testDir, "offset-limit-test.txt");
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
writeFileSync(testFile, lines.join("\n"));
const result = await readTool.execute("test-call-7", {
path: testFile,
offset: 41,
limit: 20,
});
const output = getTextOutput(result);
expect(output).not.toContain("Line 40");
expect(output).toContain("Line 41");
expect(output).toContain("Line 60");
expect(output).not.toContain("Line 61");
expect(output).toContain("[40 more lines in file. Use offset=61 to continue.]");
});
it("should show error when offset is beyond file length", async () => {
const testFile = join(testDir, "short.txt");
writeFileSync(testFile, "Line 1\nLine 2\nLine 3");
await expect(readTool.execute("test-call-8", { path: testFile, offset: 100 })).rejects.toThrow(
/Offset 100 is beyond end of file \(3 lines total\)/,
);
});
it("should include truncation details when truncated", async () => {
const testFile = join(testDir, "large-file.txt");
const lines = Array.from({ length: 2500 }, (_, i) => `Line ${i + 1}`);
writeFileSync(testFile, lines.join("\n"));
const result = await readTool.execute("test-call-9", { path: testFile });
expect(result.details).toBeDefined();
expect(result.details?.truncation).toBeDefined();
expect(result.details?.truncation?.truncated).toBe(true);
expect(result.details?.truncation?.truncatedBy).toBe("lines");
expect(result.details?.truncation?.totalLines).toBe(2500);
expect(result.details?.truncation?.outputLines).toBe(2000);
});
it("should detect image MIME type from file magic (not extension)", async () => {
const png1x1Base64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg==";
const pngBuffer = Buffer.from(png1x1Base64, "base64");
const testFile = join(testDir, "image.txt");
writeFileSync(testFile, pngBuffer);
const result = await readTool.execute("test-call-img-1", { path: testFile });
expect(result.content[0]?.type).toBe("text");
expect(getTextOutput(result)).toContain("Read image file [image/png]");
const imageBlock = result.content.find(
(c): c is { type: "image"; mimeType: string; data: string } => c.type === "image",
);
expect(imageBlock).toBeDefined();
expect(imageBlock?.mimeType).toBe("image/png");
expect(typeof imageBlock?.data).toBe("string");
expect((imageBlock?.data ?? "").length).toBeGreaterThan(0);
// Programmatic callers get the same image as a block. https://github.com/earendil-works/pi/issues/10251
expect(result.structuredContent).toEqual({
type: "image",
data: imageBlock?.data,
mimeType: "image/png",
note: getTextOutput(result),
});
});
it("should read BMP files from disk as PNG image attachments", async () => {
const testFile = join(testDir, "image.bmp");
writeFileSync(testFile, createTinyBmp1x1Red24bpp());
const result = await readTool.execute("test-call-img-bmp", { path: testFile });
expect(result.content[0]?.type).toBe("text");
expect(getTextOutput(result)).toContain("Read image file [image/png]");
expect(getTextOutput(result)).toContain("[Image converted from image/bmp to image/png.]");
const imageBlock = result.content.find(
(c): c is { type: "image"; mimeType: string; data: string } => c.type === "image",
);
expect(imageBlock).toBeDefined();
expect(imageBlock?.mimeType).toBe("image/png");
expect(Buffer.from(imageBlock?.data ?? "", "base64")[0]).toBe(0x89);
});
it("should treat files with image extension but non-image content as text", async () => {
const testFile = join(testDir, "not-an-image.png");
writeFileSync(testFile, "definitely not a png");
const result = await readTool.execute("test-call-img-2", { path: testFile });
const output = getTextOutput(result);
expect(output).toContain("definitely not a png");
expect(result.content.some((c: any) => c.type === "image")).toBe(false);
});
});
describe("write tool", () => {
it("should write file contents", async () => {
const testFile = join(testDir, "write-test.txt");
const content = "Test content";
const result = await writeTool.execute("test-call-3", { path: testFile, content });
expect(getTextOutput(result)).toBe(`Successfully wrote to ${testFile}`);
expect(result.details).toBeUndefined();
});
it("should create parent directories", async () => {
const testFile = join(testDir, "nested", "dir", "test.txt");
const content = "Nested content";
const result = await writeTool.execute("test-call-4", { path: testFile, content });
expect(getTextOutput(result)).toContain("Successfully wrote");
});
});
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");
expect(result.details.patch).toContain("--- ");
expect(result.details.patch).toContain("+++ ");
expect(result.details.patch).toContain("@@");
expect(result.details.patch).toContain("-Hello, world!");
expect(result.details.patch).toContain("+Hello, testing!");
expect(applyPatch(originalContent, result.details.patch)).toBe("Hello, 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 report non-zero exit codes as error results with structured content", async () => {
const result = await bashTool.execute("test-call-9", { command: "echo out; exit 3" });
expect(result.isError).toBe(true);
expect(getTextOutput(result)).toBe("out\n\n\nCommand exited with code 3");
expect(result.structuredContent).toEqual({
output: "out\n",
truncated: false,
exit_code: 3,
wall_time_seconds: expect.any(Number),
});
const ok = await bashTool.execute("test-call-9b", { command: "echo fine" });
expect(ok.isError).toBeUndefined();
expect(ok.structuredContent).toMatchObject({ output: "fine\n", exit_code: 0 });
const empty = await bashTool.execute("test-call-9c", { command: "true" });
expect(getTextOutput(empty)).toBe("(no output)");
expect(empty.structuredContent).toMatchObject({ output: "", truncated: false });
});
it("should return up to 1 MiB of output in structured content", async () => {
// 3000 lines exceed the model-facing 2000 line limit but not 1 MiB.
const medium = await bashTool.execute("test-call-9d", { command: "seq 1 3000" });
expect(getTextOutput(medium)).not.toContain("\n1\n2\n");
expect(medium.details?.truncation?.truncated).toBe(true);
const mediumOutput = medium.structuredContent as { output: string; truncated: boolean };
expect(mediumOutput.truncated).toBe(false);
expect(mediumOutput.output).toBe(`${Array.from({ length: 3000 }, (_, i) => i + 1).join("\n")}\n`);
// About 2 MB: keeps the first and last 512 KiB around an omission marker.
const large = await bashTool.execute("test-call-9e", { command: "seq 1 300000" });
const largeOutput = large.structuredContent as {
output: string;
truncated: boolean;
full_output_path?: string;
};
expect(largeOutput.truncated).toBe(true);
expect(largeOutput.output.startsWith("1\n2\n3\n")).toBe(true);
expect(largeOutput.output.endsWith("299999\n300000\n")).toBe(true);
expect(largeOutput.output).toMatch(/\n\n\[\.\.\. \d+ bytes omitted \.\.\.\]\n\n/);
expect(Buffer.byteLength(largeOutput.output)).toBeLessThan(1024 * 1024 + 100);
expect(largeOutput.full_output_path).toBe(large.details?.fullOutputPath);
expect(readFileSync(largeOutput.full_output_path!, "utf-8").endsWith("300000\n")).toBe(true);
});
// Regression tests for https://github.com/earendil-works/pi/issues/9577
it.skipIf(process.platform === "win32")(
"should map signal-killed commands to 128 plus the signal number",
async () => {
const operations = createLocalBashOperations();
for (const { signal, exitCode } of [
{ signal: "KILL", exitCode: 137 },
{ signal: "TERM", exitCode: 143 },
]) {
const result = await operations.exec(`kill -${signal} $$`, testDir, { onData: () => {} });
expect(result.exitCode).toBe(exitCode);
}
},
);
it.skipIf(process.platform === "win32")(
"should report signal-killed commands as errors while preserving partial output",
async () => {
for (const { signal, exitCode } of [
{ signal: "KILL", exitCode: 137 },
{ signal: "TERM", exitCode: 143 },
]) {
const result = await bashTool.execute(`test-call-signal-${signal}`, {
command: `printf 'before-kill\\n'; kill -${signal} $$`,
});
expect(result.isError).toBe(true);
expect(getTextOutput(result)).toMatch(
new RegExp(`before-kill\\s+Command exited with code ${exitCode}$`),
);
}
},
);
it("should reject a null exit code from custom operations", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
onData(Buffer.from("partial\n", "utf-8"));
return { exitCode: null };
},
};
const bash = createBashTool(testDir, { operations });
await expect(bash.execute("test-call-null-exit", { command: "remote" })).rejects.toThrow(
/partial\s+Command terminated without an exit code$/,
);
});
it("should respect timeout", async () => {
const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`;
await expect(bashTool.execute("test-call-10", { command, timeout: 0.05 })).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 () => {
const getShellConfigSpy = vi.spyOn(shellModule, "getShellConfig");
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 send commands over stdin when shell resolution requires it", async () => {
vi.spyOn(shellModule, "getShellConfig").mockReturnValue({
shell: process.execPath,
args: [
"-e",
'let input = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { process.stdout.write(input); });',
],
commandTransport: "stdin",
});
const chunks: Buffer[] = [];
const ops = createLocalBashOperations({ shellPath: "C:\\Windows\\System32\\bash.exe" });
const nameExpansion = "$" + "{name}";
const countExpansion = "$" + "{count}";
const iExpansion = "$" + "{i}";
const command = `name='World'; echo "Hello, ${nameExpansion}!"; count=3; for i in $(seq 1 ${countExpansion}); do echo "Iteration ${iExpansion} of ${countExpansion}"; done`;
const result = await ops.exec(command, testDir, {
onData: (data) => chunks.push(data),
});
expect(result.exitCode).toBe(0);
expect(Buffer.concat(chunks).toString("utf-8")).toBe(command);
});
it("should resolve legacy WSL bash.exe to stdin command transport", () => {
if (process.platform === "win32") return;
const originalCwd = process.cwd();
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const shellPath = "C:\\Windows\\System32\\bash.exe";
writeFileSync(join(testDir, shellPath), "");
try {
process.chdir(testDir);
Object.defineProperty(process, "platform", {
configurable: true,
value: "win32",
});
expect(shellModule.getShellConfig(shellPath)).toEqual({
shell: shellPath,
args: ["-s"],
commandTransport: "stdin",
});
} finally {
process.chdir(originalCwd);
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
});
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 not count a trailing newline as an extra truncated bash output line", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
for (let i = 1; i <= 4000; i++) {
onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`, "utf-8"));
}
return { exitCode: 0 };
},
};
const bash = createBashTool(testDir, { operations });
const result = await bash.execute("test-call-trailing-newline-line-count", { command: "many-lines" });
const output = getTextOutput(result);
expect(result.details?.truncation?.totalLines).toBe(4000);
expect(result.details?.truncation?.outputLines).toBe(2000);
expect(output).toContain("line-2001");
expect(output).toContain("line-4000");
expect(output).toMatch(/\[Showing lines 2001-4000 of 4000\. Full output: /);
expect(output).not.toContain("4001");
});
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("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("grep tool", () => {
it("should include filename when searching a single file", async () => {
const testFile = join(testDir, "example.txt");
writeFileSync(testFile, "first line\nmatch line\nlast line");
const result = await grepTool.execute("test-call-11", {
pattern: "match",
path: testFile,
});
const output = getTextOutput(result);
expect(output).toContain("example.txt:2: match line");
});
it("should respect global limit and include context lines", async () => {
const testFile = join(testDir, "context.txt");
const content = ["before", "match one", "after", "middle", "match two", "after two"].join("\n");
writeFileSync(testFile, content);
const result = await grepTool.execute("test-call-12", {
pattern: "match",
path: testFile,
limit: 1,
context: 1,
});
const output = getTextOutput(result);
expect(output).toContain("context.txt-1- before");
expect(output).toContain("context.txt:2: match one");
expect(output).toContain("context.txt-3- after");
expect(output).toContain("[1 matches limit reached. Use limit=2 for more, or refine pattern]");
// Ensure second match is not present
expect(output).not.toContain("match two");
});
// https://github.com/earendil-works/pi/issues/11398
it("should keep context line numbers aligned with ripgrep when a file contains a lone CR", async () => {
const testFile = join(testDir, "lone-cr.txt");
writeFileSync(testFile, "before\rprogress\nneedle\nafter");
const result = await grepTool.execute("test-call-lone-cr", {
pattern: "needle",
path: testFile,
context: 1,
});
const output = getTextOutput(result);
// A lone CR inside a line is kept, as the read tool shows it, so the text can be copied into edit.
expect(output).toContain("lone-cr.txt-1- before\rprogress");
expect(output).toContain("lone-cr.txt:2: needle");
expect(output).toContain("lone-cr.txt-3- after");
});
// https://github.com/earendil-works/pi/issues/9276
it("should print overlapping context once and keep the last match's trailing context", async () => {
const testFile = join(testDir, "rg-context.txt");
writeFileSync(testFile, ["a", "match one", "b", "match two", "match three", "c", "match four"].join("\n"));
const result = await grepTool.execute("test-call-rg-context", {
pattern: "match",
path: testFile,
context: 1,
limit: 2,
});
expect(getTextOutput(result).split("\n\n")[0]).toBe(
[
"rg-context.txt-1- a",
"rg-context.txt:2: match one",
"rg-context.txt-3- b",
"rg-context.txt:4: match two",
// A match inside the last allowed match's trailing context is shown as context.
"rg-context.txt-5- match three",
].join("\n"),
);
});
// https://github.com/earendil-works/pi/issues/9276
it("should keep counting matches past the byte cap", async () => {
const testFile = join(testDir, "byte-cap.txt");
const line = `match ${"x".repeat(400)}`;
writeFileSync(testFile, Array.from({ length: 200 }, () => line).join("\n"));
const result = await grepTool.execute("test-call-byte-cap", {
pattern: "match",
path: testFile,
limit: 150,
});
const output = getTextOutput(result);
expect(output).toContain("150 matches limit reached");
expect(output).toContain("50.0KB limit reached");
expect(result.details?.matchLimitReached).toBe(150);
expect(result.details?.truncation?.totalLines).toBe(150);
});
// https://github.com/earendil-works/pi/issues/9276
it("should report truncation when stored output ends exactly at the byte cap", async () => {
const testFile = join(testDir, "x");
// 103 rows of 500 bytes plus one of 12 bytes, with newlines, join to exactly 50KB; one more match follows.
writeFileSync(testFile, [...Array(103).fill("x".repeat(490)), "x".repeat(7), "extra x"].join("\n"));
const result = await grepTool.execute("test-call-byte-cap-boundary", {
pattern: "x",
path: testFile,
limit: 1000,
});
expect(getTextOutput(result)).toContain("[50.0KB limit reached]");
expect(result.details?.truncation?.totalLines).toBe(105);
});
// https://github.com/earendil-works/pi/issues/9276
it("should accept a huge context value", async () => {
const testFile = join(testDir, "huge-context.txt");
writeFileSync(testFile, "before\nneedle\nafter");
const result = await grepTool.execute("test-call-huge-context", {
pattern: "needle",
path: testFile,
context: 1e30,
});
expect(getTextOutput(result)).toBe(
["huge-context.txt-1- before", "huge-context.txt:2: needle", "huge-context.txt-3- after"].join("\n"),
);
});
// Context lines come from ripgrep, which decodes UTF-16; rereading the file as UTF-8 corrupted them (#11423).
it("should decode UTF-16 files with context", async () => {
const testFile = join(testDir, "utf16.txt");
writeFileSync(testFile, Buffer.from("\uFEFFbefore\nneedle 中文\nafter\n", "utf16le"));
const result = await grepTool.execute("test-call-utf16-context", {
pattern: "needle",
path: testFile,
context: 1,
});
expect(getTextOutput(result)).toBe(
["utf16.txt-1- before", "utf16.txt:2: needle 中文", "utf16.txt-3- after"].join("\n"),
);
});
it("should keep a lone CR inside a line and strip CRLF terminators", async () => {
const testFile = join(testDir, "mixed-cr.txt");
writeFileSync(testFile, "one\rtwo needle\r\nnext\r\n");
const plain = await grepTool.execute("test-call-cr-plain", { pattern: "needle", path: testFile });
expect(getTextOutput(plain)).toBe("mixed-cr.txt:1: one\rtwo needle");
const withContext = await grepTool.execute("test-call-cr-context", {
pattern: "needle",
path: testFile,
context: 1,
});
expect(getTextOutput(withContext)).toBe(
["mixed-cr.txt:1: one\rtwo needle", "mixed-cr.txt-2- next"].join("\n"),
);
});
it("should not report the match limit when exactly limit matches exist", async () => {
const testFile = join(testDir, "exact-limit.txt");
writeFileSync(testFile, ["match one", "x", "match two", "y"].join("\n"));
for (const context of [0, 1]) {
const exact = await grepTool.execute("test-call-exact-limit", {
pattern: "match",
path: testFile,
limit: 2,
context,
});
expect(getTextOutput(exact)).not.toContain("limit reached");
expect(exact.details?.matchLimitReached).toBeUndefined();
const over = await grepTool.execute("test-call-over-limit", {
pattern: "match",
path: testFile,
limit: 1,
context,
});
expect(getTextOutput(over)).toContain("[1 matches limit reached. Use limit=2 for more, or refine pattern]");
expect(over.details?.matchLimitReached).toBe(1);
}
const fractional = await grepTool.execute("test-call-fractional-limit", {
pattern: "match",
path: testFile,
limit: 1.5,
});
expect(getTextOutput(fractional)).toContain(
"[1 matches limit reached. Use limit=2 for more, or refine pattern]",
);
});
// https://github.com/earendil-works/pi/issues/11203
it("should ignore RIPGREP_CONFIG_PATH", async () => {
const configFile = join(testDir, "rg-config");
const testFile = join(testDir, "fixture.txt");
writeFileSync(configFile, "--fixed-strings\n");
writeFileSync(testFile, "aXb\na.b\n");
const previous = process.env.RIPGREP_CONFIG_PATH;
process.env.RIPGREP_CONFIG_PATH = configFile;
try {
const result = await grepTool.execute("test-call-rg-config", {
pattern: "a.b",
path: testFile,
literal: false,
});
const output = getTextOutput(result);
expect(output).toContain("fixture.txt:1: aXb");
expect(output).toContain("fixture.txt:2: a.b");
} finally {
if (previous === undefined) delete process.env.RIPGREP_CONFIG_PATH;
else process.env.RIPGREP_CONFIG_PATH = previous;
}
});
it("should treat flag-like patterns as search text", async () => {
const marker = join(testDir, "grep-injection-marker");
const payload = join(testDir, "payload.sh");
const testFile = join(testDir, "target.txt");
writeFileSync(payload, `#!/bin/sh\necho executed > ${marker}\ncat "$1"\n`);
chmodSync(payload, 0o755);
writeFileSync(testFile, "target\n");
const result = await grepTool.execute("test-call-grep-injection", {
pattern: `--pre=${payload}`,
path: testDir,
});
expect(getTextOutput(result)).toContain("No matches found");
expect(existsSync(marker)).toBe(false);
});
});
describe("find tool", () => {
it("should include hidden files that are not gitignored", async () => {
const hiddenDir = join(testDir, ".secret");
mkdirSync(hiddenDir);
writeFileSync(join(hiddenDir, "hidden.txt"), "hidden");
writeFileSync(join(testDir, "visible.txt"), "visible");
const result = await findTool.execute("test-call-13", {
pattern: "**/*.txt",
path: testDir,
});
const outputLines = getTextOutput(result)
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
expect(outputLines).toContain("visible.txt");
expect(outputLines).toContain(".secret/hidden.txt");
});
it("should respect .gitignore", async () => {
writeFileSync(join(testDir, ".gitignore"), "ignored.txt\n");
writeFileSync(join(testDir, "ignored.txt"), "ignored");
writeFileSync(join(testDir, "kept.txt"), "kept");
const result = await findTool.execute("test-call-14", {
pattern: "**/*.txt",
path: testDir,
});
const output = getTextOutput(result);
expect(output).toContain("kept.txt");
expect(output).not.toContain("ignored.txt");
});
it("should surface fd glob parse errors", async () => {
await expect(
findTool.execute("test-call-15", {
pattern: "[",
path: testDir,
}),
).rejects.toThrow(/error parsing glob|fd exited with code 1|fd error/i);
});
it("should treat flag-like patterns as search text", async () => {
const result = await findTool.execute("test-call-find-flag-pattern", {
pattern: "--help",
path: testDir,
});
expect(getTextOutput(result)).toContain("No files found matching pattern");
});
});
describe("ls tool", () => {
it("should list dotfiles and directories", async () => {
writeFileSync(join(testDir, ".hidden-file"), "secret");
mkdirSync(join(testDir, ".hidden-dir"));
const result = await lsTool.execute("test-call-15", { path: testDir });
const output = getTextOutput(result);
expect(output).toContain(".hidden-file");
expect(output).toContain(".hidden-dir/");
});
});
});
function fakeCtx(cwd: string): ExtensionToolContext {
return { cwd } as ExtensionToolContext;
}
describe("tool cwd resolution", () => {
let testDir: string;
beforeEach(() => {
testDir = join(tmpdir(), `coding-agent-cwd-test-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it("read uses ctx.cwd when provided", async () => {
const testFile = join(testDir, "ctx-cwd-read.txt");
writeFileSync(testFile, "hello from ctx.cwd");
const tool = createReadToolDefinition("/");
const result = await tool.execute(
"test-read-ctx-cwd",
{ path: "ctx-cwd-read.txt" },
undefined,
undefined,
fakeCtx(testDir),
);
const output = getTextOutput(result);
expect(output).toContain("hello from ctx.cwd");
});
it("write uses ctx.cwd when provided", async () => {
const tool = createWriteToolDefinition("/");
await tool.execute(
"test-write-ctx-cwd",
{ path: "ctx-cwd-write.txt", content: "written via ctx.cwd" },
undefined,
undefined,
fakeCtx(testDir),
);
const content = readFileSync(join(testDir, "ctx-cwd-write.txt"), "utf-8");
expect(content).toBe("written via ctx.cwd");
});
it("edit uses ctx.cwd when provided", async () => {
const testFile = join(testDir, "ctx-cwd-edit.txt");
writeFileSync(testFile, "old text");
const tool = createEditToolDefinition("/");
await tool.execute(
"test-edit-ctx-cwd",
{ path: "ctx-cwd-edit.txt", edits: [{ oldText: "old text", newText: "new text" }] },
undefined,
undefined,
fakeCtx(testDir),
);
const content = readFileSync(testFile, "utf-8");
expect(content).toBe("new text");
});
it("grep uses ctx.cwd when provided", async () => {
const testFile = join(testDir, "ctx-cwd-grep.txt");
writeFileSync(testFile, "match in ctx.cwd");
const tool = createGrepToolDefinition("/");
const result = await tool.execute(
"test-grep-ctx-cwd",
{ pattern: "match" },
undefined,
undefined,
fakeCtx(testDir),
);
const output = getTextOutput(result);
expect(output).toContain("ctx-cwd-grep.txt");
});
it("find uses ctx.cwd when provided", async () => {
writeFileSync(join(testDir, "ctx-cwd-find.txt"), "find me");
const tool = createFindToolDefinition("/");
const result = await tool.execute(
"test-find-ctx-cwd",
{ pattern: "ctx-cwd-find.txt" },
undefined,
undefined,
fakeCtx(testDir),
);
const output = getTextOutput(result);
expect(output).toContain("ctx-cwd-find.txt");
});
it("ls uses ctx.cwd when provided", async () => {
writeFileSync(join(testDir, "ctx-cwd-ls.txt"), "list me");
const tool = createLsToolDefinition("/");
const result = await tool.execute("test-ls-ctx-cwd", {}, undefined, undefined, fakeCtx(testDir));
const output = getTextOutput(result);
expect(output).toContain("ctx-cwd-ls.txt");
});
it("bash uses ctx.cwd when provided", async () => {
const tool = createBashToolDefinition("/", { exposeSessionEnvironment: false });
const result = await tool.execute(
"test-bash-ctx-cwd",
{ command: "pwd" },
undefined,
undefined,
fakeCtx(testDir),
);
const output = getTextOutput(result);
expect(output).toContain(testDir);
});
});
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");
});
it("should preserve the correct occurrence when fuzzy replacement equals a nearby line", async () => {
const testFile = join(testDir, "fuzzy-preserve-duplicate-line.txt");
const originalContent = ["replace me\u0020\u0020\u0020", "after\u0020\u0020\u0020", ""].join("\n");
writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-fuzzy-preserve-duplicate-line", {
path: testFile,
edits: [{ oldText: "replace me\n", newText: "after\n" }],
});
const expectedContent = ["after", "after\u0020\u0020\u0020", ""].join("\n");
expect(readFileSync(testFile, "utf-8")).toBe(expectedContent);
expect(applyPatch(originalContent, result.details?.patch ?? "")).toBe(expectedContent);
});
it("should preserve untouched lines and produce an applicable patch for fuzzy multi-edits", async () => {
const testFile = join(testDir, "fuzzy-preserve-multi.txt");
const originalContent = [
"keep before\u0020\u0020",
"first target\u0020\u0020",
"first after",
"keep middle\u0020\u0020\u0020",
"second target\u0020\u0020",
"second after",
"keep after\u0020\u0020",
"",
].join("\n");
writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-fuzzy-preserve-multi", {
path: testFile,
edits: [
{ oldText: "first target\nfirst after", newText: "FIRST\nFIRST2" },
{ oldText: "second target\nsecond after", newText: "SECOND\nSECOND2" },
],
});
const expectedContent = [
"keep before\u0020\u0020",
"FIRST",
"FIRST2",
"keep middle\u0020\u0020\u0020",
"SECOND",
"SECOND2",
"keep after\u0020\u0020",
"",
].join("\n");
expect(readFileSync(testFile, "utf-8")).toBe(expectedContent);
expect(applyPatch(originalContent, result.details?.patch ?? "")).toBe(expectedContent);
});
});
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");
});
});