Files
oh-my-pi/packages/coding-agent/test/tools.test.ts
T
roboomp 861d5f5909 fix(read): preserved tar hard-link members
Resolved hard-link targets after indexing so forward links and chains reuse the referenced member's storage and size. Missing, directory, or cyclic targets now surface catchable archive errors instead of silently dropping paths.

Fixes #4774
2026-08-11 16:11:16 +00:00

2310 lines
85 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as url from "node:url";
import * as zlib from "node:zlib";
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async";
import { DEFAULT_BASH_INTERCEPTOR_RULES, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { EditTool } from "@oh-my-pi/pi-coding-agent/edit";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash";
import { JobTool } from "@oh-my-pi/pi-coding-agent/tools/job";
import { wrapToolWithMetaNotice } from "@oh-my-pi/pi-coding-agent/tools/output-meta";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
import * as toolTimeouts from "@oh-my-pi/pi-coding-agent/tools/tool-timeouts";
import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write";
import { readArchiveEntries, unzip } from "@oh-my-pi/pi-coding-agent/utils/zip";
import { $which, removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
import { GlobTool } from "../src/tools/glob";
import { DEFAULT_FILE_LIMIT, GrepTool, MULTI_FILE_PER_FILE_MATCHES } from "../src/tools/grep";
// 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 writeFileWithMtime(filePath: string, content: string, mtimeMs: number): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
const mtime = new Date(mtimeMs);
fs.utimesSync(filePath, mtime, mtime);
}
function shellEscape(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function createFifoOrSkip(fifoPath: string): boolean {
if (process.platform === "win32") {
return false;
}
const mkfifoPath = $which("mkfifo");
if (!mkfifoPath) {
return false;
}
const result = Bun.spawnSync([mkfifoPath, fifoPath], { stdout: "ignore", stderr: "pipe" });
if (result.exitCode !== 0) {
const errorText = result.stderr.toString("utf-8").trim();
throw new Error(`mkfifo failed${errorText ? `: ${errorText}` : ""}`);
}
return true;
}
interface ArchiveFixtureEntry {
path: string;
content: string;
prefix?: string;
typeFlag?: "0" | "1";
linkName?: string;
}
function writeTarString(buffer: Buffer, offset: number, length: number, value: string): void {
const valueBuffer = Buffer.from(value, "utf-8");
valueBuffer.copy(buffer, offset, 0, Math.min(valueBuffer.length, length));
}
function writeTarOctal(buffer: Buffer, offset: number, length: number, value: number): void {
const octal = value.toString(8).padStart(length - 1, "0");
buffer.write(octal, offset, length - 1, "ascii");
buffer[offset + length - 1] = 0;
}
function createTarArchive(entries: ArchiveFixtureEntry[]): Buffer {
const parts: Buffer[] = [];
for (const entry of entries) {
const header = Buffer.alloc(512, 0);
const content = Buffer.from(entry.content, "utf-8");
writeTarString(header, 0, 100, entry.path);
if (entry.prefix) writeTarString(header, 345, 155, entry.prefix);
if (entry.linkName) writeTarString(header, 157, 100, entry.linkName);
writeTarOctal(header, 100, 8, 0o644);
writeTarOctal(header, 108, 8, 0);
writeTarOctal(header, 116, 8, 0);
writeTarOctal(header, 124, 12, content.length);
writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000));
header.fill(0x20, 148, 156);
header[156] = (entry.typeFlag ?? "0").charCodeAt(0);
writeTarString(header, 257, 6, "ustar");
writeTarString(header, 263, 2, "00");
let checksum = 0;
for (const byte of header) checksum += byte;
const checksumText = checksum.toString(8).padStart(6, "0");
header.write(checksumText, 148, 6, "ascii");
header[154] = 0;
header[155] = 0x20;
parts.push(header, content);
const remainder = content.length % 512;
if (remainder !== 0) {
parts.push(Buffer.alloc(512 - remainder, 0));
}
}
parts.push(Buffer.alloc(1024, 0));
return Buffer.concat(parts);
}
const CRC32_TABLE = (() => {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index++) {
let value = index;
for (let bit = 0; bit < 8; bit++) {
value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
})();
function crc32(bytes: Uint8Array): number {
let value = 0xffffffff;
for (const byte of bytes) {
value = CRC32_TABLE[(value ^ byte) & 0xff]! ^ (value >>> 8);
}
return (value ^ 0xffffffff) >>> 0;
}
function createZipArchive(entries: ArchiveFixtureEntry[]): Buffer {
const localParts: Buffer[] = [];
const centralParts: Buffer[] = [];
let localOffset = 0;
for (const entry of entries) {
const pathBuffer = Buffer.from(entry.path.replace(/\\/g, "/"), "utf-8");
const content = Buffer.from(entry.content, "utf-8");
const compressed = zlib.deflateRawSync(content);
const checksum = crc32(content);
const localHeader = Buffer.alloc(30, 0);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0x0800, 6);
localHeader.writeUInt16LE(8, 8);
localHeader.writeUInt32LE(checksum, 14);
localHeader.writeUInt32LE(compressed.length, 18);
localHeader.writeUInt32LE(content.length, 22);
localHeader.writeUInt16LE(pathBuffer.length, 26);
localParts.push(localHeader, pathBuffer, compressed);
const centralHeader = Buffer.alloc(46, 0);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(20, 4);
centralHeader.writeUInt16LE(20, 6);
centralHeader.writeUInt16LE(0x0800, 8);
centralHeader.writeUInt16LE(8, 10);
centralHeader.writeUInt32LE(checksum, 16);
centralHeader.writeUInt32LE(compressed.length, 20);
centralHeader.writeUInt32LE(content.length, 24);
centralHeader.writeUInt16LE(pathBuffer.length, 28);
centralHeader.writeUInt32LE(localOffset, 42);
centralParts.push(centralHeader, pathBuffer);
localOffset += localHeader.length + pathBuffer.length + compressed.length;
}
const centralDirectory = Buffer.concat(centralParts);
const endOfCentralDirectory = Buffer.alloc(22, 0);
endOfCentralDirectory.writeUInt32LE(0x06054b50, 0);
endOfCentralDirectory.writeUInt16LE(entries.length, 8);
endOfCentralDirectory.writeUInt16LE(entries.length, 10);
endOfCentralDirectory.writeUInt32LE(centralDirectory.length, 12);
endOfCentralDirectory.writeUInt32LE(localOffset, 16);
return Buffer.concat([...localParts, centralDirectory, endOfCentralDirectory]);
}
function createZipArchiveWithRawDeflateEntry(entry: {
path: string;
compressed: Buffer;
originalSize: number;
}): Buffer {
const pathBuffer = Buffer.from(entry.path.replace(/\\/g, "/"), "utf-8");
const localHeader = Buffer.alloc(30, 0);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0x0800, 6);
localHeader.writeUInt16LE(8, 8);
localHeader.writeUInt32LE(0, 14);
localHeader.writeUInt32LE(entry.compressed.length, 18);
localHeader.writeUInt32LE(entry.originalSize, 22);
localHeader.writeUInt16LE(pathBuffer.length, 26);
const centralHeader = Buffer.alloc(46, 0);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(20, 4);
centralHeader.writeUInt16LE(20, 6);
centralHeader.writeUInt16LE(0x0800, 8);
centralHeader.writeUInt16LE(8, 10);
centralHeader.writeUInt32LE(0, 16);
centralHeader.writeUInt32LE(entry.compressed.length, 20);
centralHeader.writeUInt32LE(entry.originalSize, 24);
centralHeader.writeUInt16LE(pathBuffer.length, 28);
const centralDirectory = Buffer.concat([centralHeader, pathBuffer]);
const endOfCentralDirectory = Buffer.alloc(22, 0);
endOfCentralDirectory.writeUInt32LE(0x06054b50, 0);
endOfCentralDirectory.writeUInt16LE(1, 8);
endOfCentralDirectory.writeUInt16LE(1, 10);
endOfCentralDirectory.writeUInt32LE(centralDirectory.length, 12);
endOfCentralDirectory.writeUInt32LE(localHeader.length + pathBuffer.length + entry.compressed.length, 16);
return Buffer.concat([localHeader, pathBuffer, entry.compressed, centralDirectory, endOfCentralDirectory]);
}
let artifactCounter = 0;
function createTestToolSession(
cwd: string,
settings: Settings = Settings.isolated(),
overrides: Partial<ToolSession> = {},
): ToolSession {
const sessionFile = path.join(cwd, "session.jsonl");
const sessionDir = path.join(cwd, "session");
return {
cwd,
hasUI: false,
getSessionFile: () => sessionFile,
getSessionSpawns: () => "*",
getArtifactsDir: () => sessionDir,
allocateOutputArtifact: async (toolType: string) => {
fs.mkdirSync(sessionDir, { recursive: true });
const id = `artifact-${++artifactCounter}`;
return { id, path: path.join(sessionDir, `${id}.${toolType}.log`) };
},
settings,
...overrides,
};
}
function createTestToolContext(toolNames: string[]): AgentToolContext {
return {
sessionManager: SessionManager.inMemory(),
modelRegistry: {
find: () => undefined,
getAll: () => [],
getApiKey: async () => undefined,
} as unknown as AgentToolContext["modelRegistry"],
model: undefined,
isIdle: () => true,
hasQueuedMessages: () => false,
abort: () => {},
toolNames,
} as AgentToolContext;
}
describe("Coding Agent Tools", () => {
let testDir: string;
let session: ToolSession;
let readTool: ReadTool;
let writeTool: WriteTool;
let editTool: EditTool;
let bashTool: BashTool;
let searchTool: GrepTool;
let findTool: GlobTool;
let originalEditVariant: string | undefined;
beforeAll(async () => {
// Warm the process-global shell snapshot + persistent session once. The
// first real bash command otherwise folds ~40ms of one-time shell setup
// into its own measured body time; this hoists it out of every test.
await new BashTool(createTestToolSession(os.tmpdir())).execute("warm-shell", { command: "true" });
});
beforeEach(() => {
// Force replace mode for edit tool tests using old_text/new_text
originalEditVariant = Bun.env.PI_EDIT_VARIANT;
Bun.env.PI_EDIT_VARIANT = "replace";
// Create a unique temporary directory for each test
testDir = path.join(os.tmpdir(), `coding-agent-test-${Snowflake.next()}`);
fs.mkdirSync(testDir, { recursive: true });
// Create tools for this test directory
session = createTestToolSession(testDir);
readTool = wrapToolWithMetaNotice(new ReadTool(session));
writeTool = wrapToolWithMetaNotice(new WriteTool(session));
editTool = wrapToolWithMetaNotice(new EditTool(session));
bashTool = wrapToolWithMetaNotice(new BashTool(session));
searchTool = wrapToolWithMetaNotice(new GrepTool(session));
findTool = wrapToolWithMetaNotice(new GlobTool(session));
});
afterEach(() => {
vi.restoreAllMocks();
// Clean up test directory
removeSyncWithRetries(testDir);
// Restore original edit variant
if (originalEditVariant === undefined) {
delete Bun.env.PI_EDIT_VARIANT;
} else {
Bun.env.PI_EDIT_VARIANT = originalEditVariant;
}
AsyncJobManager.resetForTests();
});
describe("read tool", () => {
it("should read file contents that fit within limits", async () => {
const testFile = path.join(testDir, "test.txt");
const content = "Hello, world!\nLine 2\nLine 3";
fs.writeFileSync(testFile, content);
const result = await readTool.execute("test-call-1", { path: testFile });
const output = getTextOutput(result);
expect(output).toContain("Hello, world!");
expect(output).toContain("Line 2");
expect(output).toContain("Line 3");
// No truncation message since file fits within limits
expect(getTextOutput(result)).not.toContain("Use :");
expect(result.details?.truncation).toBeUndefined();
});
it("truncates lines wider than the read column cap, leaving narrow lines untouched", async () => {
const wideLine = "x".repeat(1500);
const testFile = path.join(testDir, "wide.txt");
fs.writeFileSync(testFile, `header\n${wideLine}\nfooter`);
const result = await readTool.execute("test-call-column-truncate", { path: testFile });
const output = getTextOutput(result);
expect(output).toContain("header");
expect(output).toContain("footer");
expect(output).not.toContain(wideLine); // verbatim wide line is gone
expect(output).toContain("…"); // ellipsis marker
expect(output).toContain("Some lines truncated to 768 chars");
});
it("returns wide lines verbatim with the :raw selector", async () => {
const wideLine = "y".repeat(1500);
const testFile = path.join(testDir, "wide-raw.txt");
fs.writeFileSync(testFile, `head\n${wideLine}\ntail`);
const result = await readTool.execute("test-call-column-raw", { path: `${testFile}:raw` });
const output = getTextOutput(result);
expect(output).toContain(wideLine);
expect(output).not.toContain("Some lines truncated to 768 chars");
});
it("should read ipynb files as editable cell text", async () => {
const notebookPath = path.join(testDir, "notebook.ipynb");
const notebook = {
cells: [
{
cell_type: "markdown",
metadata: { keep: true },
source: ["# Notebook Title\n", "\n", "Notebook body\n"],
},
{
cell_type: "code",
metadata: {},
source: ["print('hello')\n"],
execution_count: 7,
outputs: [{ output_type: "stream", name: "stdout", text: "hello\n" }],
},
],
metadata: {},
nbformat: 4,
nbformat_minor: 5,
};
fs.writeFileSync(notebookPath, JSON.stringify(notebook));
const result = await readTool.execute("test-call-ipynb", { path: notebookPath });
const output = getTextOutput(result);
expect(output).toContain("# %% [markdown] cell:0");
expect(output).toContain("# Notebook Title");
expect(output).toContain("Notebook body");
expect(output).toContain("# %% [code] cell:1");
expect(output).toContain("print('hello')");
expect(output).not.toContain('"cell_type"');
});
it("should apply edits to the ipynb editable representation", async () => {
const notebookPath = path.join(testDir, "editable.ipynb");
const notebook = {
cells: [
{
cell_type: "markdown",
metadata: { keep: true },
source: ["Original title\n"],
},
{
cell_type: "code",
metadata: { trusted: true },
source: ["print('old')\n"],
execution_count: 3,
outputs: [{ output_type: "stream", name: "stdout", text: "old\n" }],
},
],
metadata: { kernelspec: { name: "python3" } },
nbformat: 4,
nbformat_minor: 5,
};
fs.writeFileSync(notebookPath, JSON.stringify(notebook));
const noLspEditTool = wrapToolWithMetaNotice(new EditTool({ ...session, enableLsp: false }));
await noLspEditTool.execute("test-edit-ipynb", {
path: notebookPath,
edits: [{ old_text: "print('old')", new_text: "print('new')" }],
});
const updated = JSON.parse(fs.readFileSync(notebookPath, "utf-8"));
expect(updated.cells).toHaveLength(2);
expect(updated.cells[1].source).toEqual(["print('new')\n"]);
expect(updated.cells[1].metadata).toEqual({ trusted: true });
expect(updated.cells[1].execution_count).toBe(3);
expect(updated.cells[1].outputs).toEqual([{ output_type: "stream", name: "stdout", text: "old\n" }]);
expect(updated.metadata).toEqual({ kernelspec: { name: "python3" } });
});
it("should handle non-existent files", async () => {
const testFile = path.join(testDir, "nonexistent.txt");
await expect(readTool.execute("test-call-2", { path: testFile })).rejects.toThrow(/ENOENT|not found/i);
});
it("should read local files passed as file:// URLs", async () => {
const testFile = path.join(testDir, "file-url.txt");
fs.writeFileSync(testFile, "Hello from file URL");
const result = await readTool.execute("test-call-file-url", { path: url.pathToFileURL(testFile).href });
const output = getTextOutput(result);
expect(output).toContain("Hello from file URL");
});
it("should truncate files exceeding line limit", async () => {
const testFile = path.join(testDir, "large.txt");
const lines = Array.from({ length: 3500 }, (_, i) => `Line ${i + 1}`);
fs.writeFileSync(testFile, lines.join("\n"));
const defaultLimit = session.settings.get("read.defaultLimit");
const result = await readTool.execute("test-call-3", { path: testFile });
const output = getTextOutput(result);
expect(output).toContain("Line 1");
expect(output).toContain(`Line ${defaultLimit}`);
expect(output).not.toContain(`Line ${defaultLimit + 1}`);
expect(output).toContain(`[Showing lines 1-${defaultLimit} of 3500. Use :${defaultLimit + 1} to continue]`);
});
it("should truncate when byte limit exceeded", async () => {
const testFile = path.join(testDir, "large-bytes.txt");
// Create file with long lines so the byte budget triggers before the line limit.
const lines = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}: ${"x".repeat(600)}`);
fs.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 1000 \(\d+(\.\d+)?\s*KB limit\)\. Use :\d+ to continue\]/);
});
it("should handle offset parameter (with leading context expansion)", async () => {
const testFile = path.join(testDir, "offset-test.txt");
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
fs.writeFileSync(testFile, lines.join("\n"));
const result = await readTool.execute("test-call-5", { path: `${testFile}:L51` });
const output = getTextOutput(result);
// Read tool widens by 1 leading + 3 trailing unanchored context lines
// so anchors at the boundary stay fresh. Line 50 is the single leading
// context line; lines 47..49 are NOT included.
expect(output).not.toContain("Line 49");
expect(output).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 :");
});
it("should handle limit parameter (with trailing context expansion)", async () => {
const testFile = path.join(testDir, "limit-test.txt");
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
fs.writeFileSync(testFile, lines.join("\n"));
const result = await readTool.execute("test-call-6", { path: `${testFile}:L1-L10` });
const output = getTextOutput(result);
// Trailing context: lines 11..13 included so an edit anchored at
// the boundary stays fresh.
expect(output).toContain("Line 1");
expect(output).toContain("Line 10");
expect(output).toContain("Line 13");
expect(output).not.toContain("Line 14");
expect(output).toContain("[Showing lines 1-13 of 100. Use :14 to continue]");
});
it("does not expand on the leading side when offset is 1 or unspecified", async () => {
const testFile = path.join(testDir, "no-leading.txt");
const lines = Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`);
fs.writeFileSync(testFile, lines.join("\n"));
// :L1-L5 has offset=1 → no leading context (already at the top).
// Trailing context still applies.
const result = await readTool.execute("test-no-leading", {
path: `${testFile}:L1-L5`,
});
const output = getTextOutput(result);
expect(output).toContain("Line 1");
expect(output).toContain("Line 5");
expect(output).toContain("Line 8");
expect(output).not.toContain("Line 9");
expect(output).toContain("[Showing lines 1-8 of 50. Use :9 to continue]");
});
it("clamps leading context at file start without errors", async () => {
const testFile = path.join(testDir, "leading-clamp.txt");
const lines = Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`);
fs.writeFileSync(testFile, lines.join("\n"));
// :L2-L5: offset=2 → expand by min(1, 1) = 1 leading line.
const result = await readTool.execute("test-leading-clamp", {
path: `${testFile}:L2-L5`,
});
const output = getTextOutput(result);
expect(output).toContain("Line 1");
expect(output).toContain("Line 2");
expect(output).toContain("Line 5");
expect(output).toContain("Line 8");
expect(output).not.toContain("Line 9");
});
it("should handle offset + limit together (1 leading + 3 trailing)", async () => {
const testFile = path.join(testDir, "offset-limit-test.txt");
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
fs.writeFileSync(testFile, lines.join("\n"));
const result = await readTool.execute("test-call-7", {
path: `${testFile}:L41-L60`,
});
const output = getTextOutput(result);
// Both endpoints are user-constrained: 1 leading + 3 trailing.
expect(output).not.toContain("Line 39");
expect(output).toContain("Line 40");
expect(output).toContain("Line 41");
expect(output).toContain("Line 60");
expect(output).toContain("Line 63");
expect(output).not.toContain("Line 64");
expect(output).toContain("[Showing lines 40-63 of 100. Use :64 to continue]");
});
it("should show error when offset is beyond file length", async () => {
const testFile = path.join(testDir, "short.txt");
fs.writeFileSync(testFile, "Line 1\nLine 2\nLine 3");
const result = await readTool.execute("test-call-8", { path: `${testFile}:L100` });
const output = getTextOutput(result);
expect(output).toContain("Line 100 is beyond end of file (3 lines total)");
expect(output).toContain("Use :1 to read from the start, or :3 to read the last line.");
});
it("should refuse binary files (NUL or invalid UTF-8) instead of emitting mojibake", async () => {
const nulFile = path.join(testDir, "blob.bin");
fs.writeFileSync(nulFile, Buffer.from([0x61, 0x62, 0x63, 0x00, 0xff, 0xfe, 0x64, 0x65]));
// A header with no NUL but invalid UTF-8 (lone 0xFF/0xC0) must also refuse.
const invalidUtf8File = path.join(testDir, "font.ttfish");
fs.writeFileSync(invalidUtf8File, Buffer.from([0x4d, 0x5a, 0xff, 0xfe, 0xc0, 0xc0, 0x90, 0x91]));
for (const file of [nulFile, invalidUtf8File]) {
const output = getTextOutput(await readTool.execute("test-call-binary", { path: file }));
expect(output).toContain("Cannot read binary file");
expect(output).not.toContain("\u0000");
expect(output).not.toContain("\uFFFD");
}
});
it("reads a binary file verbatim when :raw is requested", async () => {
const testFile = path.join(testDir, "raw-blob.bin");
fs.writeFileSync(testFile, Buffer.from([0x61, 0x62, 0x63, 0x00, 0x64, 0x65]));
const output = getTextOutput(await readTool.execute("test-call-binary-raw", { path: `${testFile}:raw` }));
expect(output).not.toContain("Cannot read binary file");
});
it("should reject malformed internal-URL selectors instead of dumping the whole resource", async () => {
await expect(readTool.execute("test-call-bad-internal-sel", { path: "artifact://3:-100" })).rejects.toThrow(
/Invalid selector ':-100'/,
);
});
it("should include truncation details when truncated", async () => {
const testFile = path.join(testDir, "large-file.txt");
const lines = Array.from({ length: 3500 }, (_, i) => `Line ${i + 1}`);
fs.writeFileSync(testFile, lines.join("\n"));
const defaultLimit = session.settings.get("read.defaultLimit");
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(3500);
expect(result.details?.truncation?.outputLines).toBe(defaultLimit);
});
it("should render directories as a two-level tree without capping root entries", async () => {
const childDir = path.join(testDir, "child");
const base = Date.now() - 60_000;
fs.mkdirSync(childDir, { recursive: true });
writeFileWithMtime(path.join(testDir, ".hidden-root"), "hidden", base + 20_000);
writeFileWithMtime(path.join(testDir, ".DS_Store"), "mac metadata", base + 25_000);
writeFileWithMtime(path.join(testDir, "node_modules", "pkg", "index.js"), "ignored", base + 24_000);
for (let i = 0; i < 13; i += 1) {
const fileName = `root-${String(i).padStart(2, "0")}.txt`;
writeFileWithMtime(path.join(testDir, fileName), fileName, base + i);
}
for (let i = 0; i < 13; i += 1) {
const fileName = `child-${String(i).padStart(2, "0")}.txt`;
writeFileWithMtime(path.join(childDir, fileName), fileName, base + i);
}
writeFileWithMtime(path.join(childDir, "nested", "deep.txt"), "deep", base + 30_000);
const result = await readTool.execute("test-call-directory-tree", { path: testDir });
const output = getTextOutput(result);
expect(result.details?.isDirectory).toBe(true);
expect(output).toContain(".");
expect(output).toContain(".hidden-root");
expect(output).not.toContain(".DS_Store");
expect(output).not.toContain("node_modules");
expect(output).toContain("root-00.txt");
expect(output).toContain("root-01.txt");
expect(output).toContain("root-12.txt");
expect(output).toContain("child/");
expect(output).toContain("nested/");
expect(output).toContain("… 2 more");
expect(output).not.toContain("child-01.txt");
expect(output).toContain("child-00.txt");
expect(output).not.toContain("deep.txt");
});
it("should treat .tar archives like directories", async () => {
const archivePath = path.join(testDir, "fixture.tar");
fs.writeFileSync(
archivePath,
createTarArchive([
{ path: "pkg/README.md", content: "# Tar README\nLine 2\n" },
{ path: "pkg/src/index.ts", content: "export const tarValue = 1;\n" },
{ path: "top.txt", content: "top level\n" },
]),
);
const result = await readTool.execute("test-call-tar-root", { path: archivePath });
const output = getTextOutput(result);
expect(output).toContain("pkg/");
expect(output).toContain("top.txt");
expect(result.details?.isDirectory).toBe(true);
});
it("should read tar.gz members with UTF-8 ustar prefixes", async () => {
const archivePath = path.join(testDir, "unicode-prefix.tar.gz");
const prefix = "bun-da3851e57ae130c5594d0e208a5da5ba8c13edfb/test/js/node/test/fixtures/copy/utf/新建文件夹";
const memberPath = `${prefix}/experimental.json`;
fs.writeFileSync(
archivePath,
zlib.gzipSync(
createTarArchive([
{
path: "experimental.json",
prefix,
content: '{ "type": "module" }',
},
]),
),
);
const rootResult = await readTool.execute("test-call-tar-unicode-prefix-root", { path: archivePath });
expect(getTextOutput(rootResult)).toContain("bun-da3851e57ae130c5594d0e208a5da5ba8c13edfb/");
expect(rootResult.details?.isDirectory).toBe(true);
const memberResult = await readTool.execute("test-call-tar-unicode-prefix-member", {
path: `${archivePath}:${memberPath}`,
});
expect(getTextOutput(memberResult)).toContain('{ "type": "module" }');
});
it("should preserve tar hard-link members", async () => {
const archivePath = path.join(testDir, "hard-link.tar");
fs.writeFileSync(
archivePath,
createTarArchive([
{ path: "pkg/original.txt", content: "shared content\n" },
{ path: "pkg/linked.txt", content: "", typeFlag: "1", linkName: "pkg/original.txt" },
]),
);
const rootResult = await readTool.execute("test-call-tar-hard-link-root", { path: `${archivePath}:pkg` });
expect(getTextOutput(rootResult)).toContain("linked.txt");
const linkedResult = await readTool.execute("test-call-tar-hard-link-member", {
path: `${archivePath}:pkg/linked.txt`,
});
expect(getTextOutput(linkedResult)).toContain("shared content");
const entries = await readArchiveEntries(archivePath);
const linkedContent = entries.get("pkg/linked.txt");
if (!(linkedContent instanceof Uint8Array)) {
throw new Error("Expected hard-link content to materialize as bytes");
}
expect(new TextDecoder().decode(linkedContent)).toBe("shared content\n");
});
it("should reject a truncated tar member while indexing", async () => {
const archivePath = path.join(testDir, "truncated.tar");
// A full, valid archive declares 2048 bytes for `big.txt`; slicing the
// payload mid-member leaves the header's declared size pointing past EOF.
const complete = createTarArchive([{ path: "big.txt", content: "A".repeat(2048) }]);
fs.writeFileSync(archivePath, complete.subarray(0, 512 + 256));
await expect(readTool.execute("test-call-tar-truncated", { path: archivePath })).rejects.toThrow(/truncated/);
});
it("should reject a gzip payload that is not a tar archive", async () => {
// `sniffArchiveFormat` classifies any gzip magic as tar.gz, so a plain
// `.txt.gz` (decompressed payload shorter than one 512-byte tar block)
// must raise a catchable error instead of listing an empty directory.
const archivePath = path.join(testDir, "note.tar.gz");
fs.writeFileSync(archivePath, zlib.gzipSync(Buffer.from("hello world\n")));
await expect(readTool.execute("test-call-gzip-non-tar", { path: archivePath })).rejects.toThrow(
/not a valid tar archive/i,
);
});
it("should list archive subdirectories", async () => {
const archivePath = path.join(testDir, "fixture.zip");
fs.writeFileSync(
archivePath,
createZipArchive([
{ path: "pkg/README.md", content: "# Zip README\n" },
{ path: "pkg/src/index.ts", content: "export const zipValue = 2;\n" },
{ path: "pkg/src/util.ts", content: "export const utilValue = 3;\n" },
]),
);
const result = await readTool.execute("test-call-zip-dir", { path: `${archivePath}:pkg/src` });
const output = getTextOutput(result);
expect(output).toContain("index.ts");
expect(output).toContain("util.ts");
expect(result.details?.isDirectory).toBe(true);
});
it("should list zip archives without inflating member payloads", async () => {
const archivePath = path.join(testDir, "header-only.zip");
fs.writeFileSync(
archivePath,
createZipArchiveWithRawDeflateEntry({
path: "corrupt.bin",
compressed: Buffer.from([0xff, 0xff, 0xff, 0xff]),
originalSize: 1024,
}),
);
const result = await readTool.execute("test-call-zip-header-only", { path: archivePath });
const output = getTextOutput(result);
expect(output).toContain("corrupt.bin");
expect(result.details?.isDirectory).toBe(true);
});
for (const archiveCase of [
{
label: ".tar",
path: "fixture-subpath.tar",
create: (entries: ArchiveFixtureEntry[]) => createTarArchive(entries),
},
{
label: ".tar.gz",
path: "fixture-subpath.tar.gz",
create: (entries: ArchiveFixtureEntry[]) => zlib.gzipSync(createTarArchive(entries)),
},
{
label: ".tgz",
path: "fixture-subpath.tgz",
create: (entries: ArchiveFixtureEntry[]) => zlib.gzipSync(createTarArchive(entries)),
},
{
label: ".zip",
path: "fixture-subpath.zip",
create: (entries: ArchiveFixtureEntry[]) => createZipArchive(entries),
},
]) {
it(`should read ${archiveCase.label} subpaths`, async () => {
const archivePath = path.join(testDir, archiveCase.path);
fs.writeFileSync(
archivePath,
archiveCase.create([
{ path: "pkg/README.md", content: "# Archive README\nLine 2\nLine 3\n" },
{ path: "pkg/src/index.ts", content: "export const archiveValue = 4;\n" },
]),
);
const result = await readTool.execute("test-call-archive-subpath", {
path: `${archivePath}:pkg/README.md:L1-L2`,
});
const output = getTextOutput(result);
expect(output).toContain("# Archive README");
expect(output).toContain("Line 2");
// Trailing context (±3) keeps Line 3 visible when present.
expect(output).toContain("Line 3");
});
}
it("should treat a selector-shaped archive subpath as a root listing selector", async () => {
const archivePath = path.join(testDir, "root-selector.tar");
fs.writeFileSync(
archivePath,
createTarArchive([
{ path: "alpha.txt", content: "alpha\n" },
{ path: "beta.txt", content: "beta\n" },
]),
);
// Previously misparsed as a member named "2" and failed with a
// misleading "not found inside archive" error. The selector is honored
// as a 1-indexed listing offset, so `:2` starts at the second entry.
const result = await readTool.execute("test-call-archive-root-selector", { path: `${archivePath}:2` });
const output = getTextOutput(result);
expect(output).toContain("beta.txt");
expect(output).not.toContain("alpha.txt");
expect(result.details?.isDirectory).toBe(true);
});
it("should prefer an archive member over a selector-shaped name", async () => {
const archivePath = path.join(testDir, "member-precedence.tar");
fs.writeFileSync(archivePath, createTarArchive([{ path: "raw", content: "member named raw\n" }]));
const result = await readTool.execute("test-call-archive-member-raw", { path: `${archivePath}:raw` });
const output = getTextOutput(result);
expect(output).toContain("member named raw");
});
it("should reject archive members larger than the in-memory extraction cap", async () => {
const archivePath = path.join(testDir, "bomb.zip");
fs.writeFileSync(
archivePath,
createZipArchiveWithRawDeflateEntry({
path: "bomb.bin",
compressed: Buffer.from([0xff, 0xff, 0xff, 0xff]),
originalSize: 3 * 1024 * 1024 * 1024, // 3GB declared, never allocated
}),
);
await expect(readTool.execute("test-call-archive-bomb", { path: `${archivePath}:bomb.bin` })).rejects.toThrow(
/too large to extract/i,
);
});
it("should detect image MIME type from file magic (not extension)", async () => {
const png1x1Base64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2Z0AAAAASUVORK5CYII=";
const pngBuffer = Buffer.from(png1x1Base64, "base64");
const testFile = path.join(testDir, "image.txt");
fs.writeFileSync(testFile, pngBuffer);
const legacyReadTool = wrapToolWithMetaNotice(
new ReadTool(
createTestToolSession(
testDir,
Settings.isolated({ "inspect_image.enabled": false, "images.autoResize": false }),
),
),
);
const result = await legacyReadTool.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);
});
it("returns metadata guidance (no image blocks) when inspect_image is enabled", async () => {
const png1x1Base64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2Z0AAAAASUVORK5CYII=";
const pngBuffer = Buffer.from(png1x1Base64, "base64");
const testFile = path.join(testDir, "image-guidance.png");
fs.writeFileSync(testFile, pngBuffer);
const inspectModeReadTool = wrapToolWithMetaNotice(
new ReadTool(createTestToolSession(testDir, Settings.isolated({ "inspect_image.enabled": true }))),
);
const result = await inspectModeReadTool.execute("test-call-img-guidance", { path: testFile });
const output = getTextOutput(result);
expect(output).toContain("Image metadata:");
expect(output).toContain("MIME: image/png");
expect(output).toContain("Bytes:");
expect(output).toContain("Dimensions:");
expect(output).toContain("inspect_image");
expect(output).toContain(`path="${path.basename(testFile)}"`);
expect(output).toContain("question");
expect(output).not.toContain("optional context");
expect(result.content.some(c => c.type === "image")).toBe(false);
});
it("omits inspect_image from the description when the tool is disabled", () => {
const enabled = new ReadTool(
createTestToolSession(testDir, Settings.isolated({ "inspect_image.enabled": true })),
);
const disabled = new ReadTool(
createTestToolSession(testDir, Settings.isolated({ "inspect_image.enabled": false })),
);
expect(enabled.description).toContain("inspect_image");
expect(disabled.description).not.toContain("inspect_image");
expect(disabled.description).toContain("inline");
});
it("should treat files with image extension but non-image content as text", async () => {
const testFile = path.join(testDir, "not-an-image.png");
fs.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 = path.join(testDir, "write-test.txt");
const content = "Test content";
const result = await writeTool.execute("test-call-3", { path: testFile, content });
expect(getTextOutput(result)).toContain("Successfully wrote");
expect(getTextOutput(result)).toContain(path.basename(testFile));
});
it("should create parent directories", async () => {
const testFile = path.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");
});
it.skipIf(process.platform === "win32")(
"should tell the model when a shebang write was chmodded executable",
async () => {
const testFile = path.join(testDir, "script.sh");
const content = "#!/usr/bin/env bash\nprintf 'ok\\n'\n";
const result = await writeTool.execute("test-call-3-shebang", { path: testFile, content });
expect(getTextOutput(result)).toContain("[Notice: Made executable via chmod +x]");
expect(result.details?.madeExecutable).toBe(true);
expect(fs.statSync(testFile).mode & 0o111).toBe(0o111);
},
);
it("should write to a new local:// path under the session local root", async () => {
const localPath = "local://handoffs/new-output.json";
const content = '{"ok":true}\n';
const expectedPath = path.join(testDir, "session", "local", "handoffs", "new-output.json");
const result = await writeTool.execute("test-call-4-local", { path: localPath, content });
expect(getTextOutput(result)).toContain(
`Successfully wrote ${content.length} bytes to session/local/handoffs/new-output.json`,
);
expect(fs.existsSync(expectedPath)).toBe(true);
expect(fs.readFileSync(expectedPath, "utf-8")).toBe(content);
});
it("should write to an existing archive entry", async () => {
const archivePath = path.join(testDir, "write-existing.zip");
fs.writeFileSync(
archivePath,
createZipArchive([
{ path: "pkg/README.md", content: "# Original\n" },
{ path: "pkg/src/index.ts", content: "export const archiveValue = 1;\n" },
]),
);
const content = "# Updated\nLine 2\n";
const result = await writeTool.execute("test-call-archive-write-existing", {
path: `${archivePath}:pkg/README.md`,
content,
});
expect(getTextOutput(result)).toContain(
`Successfully wrote ${content.length} bytes to ${path.basename(archivePath)}:pkg/README.md`,
);
const unzipped = unzip(new Uint8Array(fs.readFileSync(archivePath)));
expect(new TextDecoder().decode(unzipped["pkg/README.md"])).toBe(content);
expect(new TextDecoder().decode(unzipped["pkg/src/index.ts"])).toBe("export const archiveValue = 1;\n");
});
it("should create a new archive when writing to an archive subpath", async () => {
const archivePath = path.join(testDir, "nested", "created.tar.gz");
const content = "created inside archive\n";
const result = await writeTool.execute("test-call-archive-write-create", {
path: `${archivePath}:pkg/new.txt`,
content,
});
expect(getTextOutput(result)).toContain(
`Successfully wrote ${content.length} bytes to nested/${path.basename(archivePath)}:pkg/new.txt`,
);
expect(fs.existsSync(archivePath)).toBe(true);
const archive = new Bun.Archive(await Bun.file(archivePath).bytes());
const files = await archive.files();
expect(await files.get("pkg/new.txt")?.text()).toBe(content);
});
it("should preserve gzip compression when writing into an existing .tar.gz", async () => {
const archivePath = path.join(testDir, "write-existing.tar.gz");
fs.writeFileSync(
archivePath,
zlib.gzipSync(
createTarArchive([
{ path: "pkg/README.md", content: "# Original\n" },
{ path: "pkg/src/index.ts", content: "export const archiveValue = 1;\n" },
]),
),
);
const content = "# Updated\nLine 2\n";
await writeTool.execute("test-call-archive-write-targz", {
path: `${archivePath}:pkg/README.md`,
content,
});
const bytes = fs.readFileSync(archivePath);
// gzip magic must survive the rewrite (regression: archive was
// silently rewritten as a bare tar under the .gz name).
expect(bytes[0]).toBe(0x1f);
expect(bytes[1]).toBe(0x8b);
const archive = new Bun.Archive(await Bun.file(archivePath).bytes());
const files = await archive.files();
expect(await files.get("pkg/README.md")?.text()).toBe(content);
expect(await files.get("pkg/src/index.ts")?.text()).toBe("export const archiveValue = 1;\n");
});
it("should treat a plain archive filename as a regular file write", async () => {
const archivePath = path.join(testDir, "literal.zip");
const content = "plain file contents\n";
const result = await writeTool.execute("test-call-archive-plain-file", {
path: archivePath,
content,
});
expect(getTextOutput(result)).toContain(
`Successfully wrote ${content.length} bytes to ${path.basename(archivePath)}`,
);
expect(fs.readFileSync(archivePath, "utf-8")).toBe(content);
});
});
describe("edit tool", () => {
it("should replace text in file", async () => {
const testFile = path.join(testDir, "edit-test.txt");
const originalContent = "Hello, world!";
fs.writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-call-5", {
path: testFile,
edits: [{ old_text: "world", new_text: "testing" }],
});
const details = result.details as { diff?: string } | undefined;
expect(getTextOutput(result)).toContain("Successfully replaced");
expect(details).toBeDefined();
expect(details?.diff).toBeDefined();
expect(typeof details?.diff).toBe("string");
expect(details?.diff).toContain("testing");
});
it("should fail if text not found", async () => {
const testFile = path.join(testDir, "edit-test.txt");
const originalContent = "Hello, world!";
fs.writeFileSync(testFile, originalContent);
await expect(
editTool.execute("test-call-6", {
path: testFile,
edits: [{ old_text: "nonexistent", new_text: "testing" }],
}),
).rejects.toThrow(/Could not find/);
});
it("should fail if text appears multiple times", async () => {
const testFile = path.join(testDir, "edit-test.txt");
const originalContent = "foo foo foo";
fs.writeFileSync(testFile, originalContent);
await expect(
editTool.execute("test-call-7", {
path: testFile,
edits: [{ old_text: "foo", new_text: "bar" }],
}),
).rejects.toThrow(/Found 3 occurrences/);
});
it("should replace all occurrences with all: true", async () => {
const testFile = path.join(testDir, "edit-all-test.txt");
fs.writeFileSync(testFile, "foo bar foo baz foo");
const result = await editTool.execute("test-all-1", {
path: testFile,
edits: [{ old_text: "foo", new_text: "qux", all: true }],
});
expect(getTextOutput(result)).toContain("Successfully replaced 3 occurrences");
const content = await Bun.file(testFile).text();
expect(content).toBe("qux bar qux baz qux");
});
it("should reject all: true when multiple fuzzy matches are ambiguous", async () => {
const testFile = path.join(testDir, "edit-all-fuzzy.txt");
// File has two similar blocks with different indentation
fs.writeFileSync(
testFile,
`function a() {
if (x) {
doThing();
}
}
function b() {
if (x) {
doThing();
}
}
`,
);
// With multiple fuzzy matches, the tool rejects for safety to avoid ambiguous replacements
await expect(
editTool.execute("test-all-fuzzy", {
path: testFile,
edits: [
{
old_text: "if (x) {\n doThing();\n}",
new_text: "if (y) {\n doOther();\n}",
all: true,
},
],
}),
).rejects.toThrow(/Found 2 high-confidence matches/);
});
it("should fail with all: true if no matches found", async () => {
const testFile = path.join(testDir, "edit-all-nomatch.txt");
fs.writeFileSync(testFile, "hello world");
await expect(
editTool.execute("test-all-nomatch", {
path: testFile,
edits: [{ old_text: "nonexistent", new_text: "bar", all: true }],
}),
).rejects.toThrow(/Could not find/);
});
it("should replace multiline text with all: true", async () => {
const testFile = path.join(testDir, "edit-all-multiline.txt");
fs.writeFileSync(testFile, "start\nfoo\nbar\nend\nstart\nfoo\nbar\nend");
const result = await editTool.execute("test-all-multiline", {
path: testFile,
edits: [{ old_text: "foo\nbar", new_text: "replaced", all: true }],
});
expect(getTextOutput(result)).toContain("Successfully replaced 2 occurrences");
const content = await Bun.file(testFile).text();
expect(content).toBe("start\nreplaced\nend\nstart\nreplaced\nend");
});
it("should work with all: true when only one occurrence exists", async () => {
const testFile = path.join(testDir, "edit-all-single.txt");
fs.writeFileSync(testFile, "hello world");
const result = await editTool.execute("test-all-single", {
path: testFile,
edits: [{ old_text: "world", new_text: "universe", all: true }],
});
expect(getTextOutput(result)).toContain("Successfully replaced text");
const content = await Bun.file(testFile).text();
expect(content).toBe("hello universe");
});
});
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?.timeoutSeconds).toBe(300);
});
it("should record wall time in text content and details", async () => {
const result = await bashTool.execute("test-call-walltime", { command: "echo wt" });
const output = getTextOutput(result);
expect(output).toContain("wt");
expect(output).toMatch(/Wall time: \d+\.\d{2} seconds/);
expect(typeof result.details?.wallTimeMs).toBe("number");
expect(result.details?.wallTimeMs).toBeGreaterThanOrEqual(0);
});
it("should expose built-in interceptor defaults truthfully", () => {
const defaultSettings = Settings.isolated({ "bashInterceptor.enabled": true });
const explicitEmptySettings = Settings.isolated({
"bashInterceptor.enabled": true,
"bashInterceptor.patterns": [],
});
expect(defaultSettings.get("bashInterceptor.patterns")).toEqual(DEFAULT_BASH_INTERCEPTOR_RULES);
expect(defaultSettings.getBashInterceptorRules()).toEqual(DEFAULT_BASH_INTERCEPTOR_RULES);
expect(explicitEmptySettings.get("bashInterceptor.patterns")).toEqual([]);
expect(explicitEmptySettings.getBashInterceptorRules()).toEqual([]);
});
it("should block built-in interceptor commands when enabled with default patterns", async () => {
const interceptedBashTool = wrapToolWithMetaNotice(
new BashTool(createTestToolSession(testDir, Settings.isolated({ "bashInterceptor.enabled": true }))),
);
await expect(
interceptedBashTool.execute(
"test-call-8-intercept-default",
{ command: "cat test.txt" },
undefined,
undefined,
createTestToolContext(["read"]),
),
).rejects.toThrow(/Use the `read` tool instead of cat\/head\/tail/);
});
it("should allow an explicit empty interceptor pattern list", async () => {
const allowedFile = path.join(testDir, "allow-empty.txt");
fs.writeFileSync(allowedFile, "empty means empty\n");
const interceptedBashTool = wrapToolWithMetaNotice(
new BashTool(
createTestToolSession(
testDir,
Settings.isolated({
"bashInterceptor.enabled": true,
"bashInterceptor.patterns": [],
}),
),
),
);
const result = await interceptedBashTool.execute(
"test-call-8-intercept-empty",
{ command: `cat ${shellEscape(allowedFile)}` },
undefined,
undefined,
createTestToolContext(["read"]),
);
expect(getTextOutput(result)).toContain("empty means empty");
});
it("should honor custom bash interceptor patterns", async () => {
const interceptedBashTool = wrapToolWithMetaNotice(
new BashTool(
createTestToolSession(
testDir,
Settings.isolated({
"bashInterceptor.enabled": true,
"bashInterceptor.patterns": [
{
pattern: "^\\s*customcmd\\s+",
tool: "grep",
message: "Use the `grep` tool for customcmd.",
},
],
}),
),
),
);
await expect(
interceptedBashTool.execute(
"test-call-8-intercept-custom",
{ command: "customcmd foo" },
undefined,
undefined,
createTestToolContext(["grep"]),
),
).rejects.toThrow(/Use the `grep` tool for customcmd\./);
});
it("should expose env values without shell re-parsing", async () => {
const mermaid = [
"flowchart TD",
'N0["attack"]',
'N1["[target] cluster"]',
'N2["diff-review"]',
'N3["extract"]',
'N4["report"]',
'N5["setup"]',
"N3 --> N0",
"N0 --> N1",
"N2 --> N1",
"N3 --> N2",
"N5 --> N3",
"N1 --> N4",
].join("\n");
const result = await bashTool.execute("test-call-8-env", {
command: "printf '%s' \"$MERMAID\"",
env: { MERMAID: mermaid },
});
const output = getTextOutput(result);
expect(output).toContain('N0["attack"]');
expect(output).toContain("N1 --> N4");
expect(fs.existsSync(path.join(testDir, "N0"))).toBe(false);
expect(fs.existsSync(path.join(testDir, "N4"))).toBe(false);
});
it("should resolve local:// destination paths for mv commands", async () => {
const sourcePath = path.join(testDir, "move-source.json");
const targetPath = path.join(testDir, "session", "local", "moved-via-bash.json");
fs.writeFileSync(sourcePath, '{"move":true}\n');
await bashTool.execute("test-call-8-local-mv", {
command: `mv ${shellEscape(sourcePath)} local://moved-via-bash.json`,
});
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.existsSync(targetPath)).toBe(true);
expect(fs.readFileSync(targetPath, "utf-8")).toBe('{"move":true}\n');
});
it("should stream output updates", async () => {
const updates: string[] = [];
const result = await bashTool.execute(
"test-call-8-stream",
{ command: "printf '1\\n'; sleep 0.03; printf '2\\n3\\n'" },
undefined,
update => {
const text = update.content?.find(c => c.type === "text")?.text ?? "";
updates.push(text);
},
);
expect(updates.length).toBeGreaterThan(1);
expect(getTextOutput(result)).toContain("1");
expect(getTextOutput(result)).toContain("3");
});
it("should persist environment variables between commands", async () => {
if (process.platform === "win32" || Bun.env.PI_SHELL_PERSIST !== "1") {
return;
}
await bashTool.execute("test-call-8-env-set", { command: "export PI_TEST_VAR=hello" });
const result = await bashTool.execute("test-call-8-env-get", { command: "echo $PI_TEST_VAR" });
expect(getTextOutput(result)).toContain("hello");
});
it("should write truncated output to artifacts", async () => {
const result = await bashTool.execute("test-call-8-artifact", {
// A single line past the 768-byte column cap is the minimal output
// that trips truncation + artifact spill; the old 60K-arg brace
// expansion paid ~60ms of shell time to prove the same path.
command: "printf 'a%.0s' {1..2000}",
});
const artifactId = result.details?.meta?.truncation?.artifactId;
expect(artifactId).toBeDefined();
if (artifactId) {
const artifactPath = path.join(testDir, "session", `${artifactId}.bash.log`);
expect(fs.existsSync(artifactPath)).toBe(true);
}
});
it("should surface non-zero exits as an error result", async () => {
// A completed-but-failed command resolves as a non-throwing error
// result carrying the exit code, so the renderer keeps its footer.
const result = await bashTool.execute("test-call-9", { command: "exit 1" });
expect(result.isError).toBe(true);
expect(result.details?.exitCode).toBe(1);
expect(getTextOutput(result)).toContain("Command exited with code 1");
});
it("should keep short commands inline when auto-background is enabled", async () => {
const deliveries: string[] = [];
const asyncJobManager = new AsyncJobManager({
onJobComplete: async (_jobId, text) => {
deliveries.push(text);
},
});
const autoBackgroundBashTool = wrapToolWithMetaNotice(
new BashTool(
createTestToolSession(
testDir,
Settings.isolated({
"bash.autoBackground.enabled": true,
"bash.autoBackground.thresholdMs": 2_000,
}),
{
getSessionId: () => "test-session",
asyncJobManager,
},
),
),
);
const result = await autoBackgroundBashTool.execute("test-call-9-auto-inline", { command: "echo short" });
expect(getTextOutput(result)).toContain("short");
expect(result.details?.timeoutSeconds).toBe(300);
expect(result.details?.async).toBeUndefined();
await asyncJobManager.drainDeliveries({ timeoutMs: 1 });
expect(deliveries).toEqual([]);
await asyncJobManager.dispose();
});
it("should auto-background long-running commands when enabled", async () => {
const deliveries: Array<{ jobId: string; text: string }> = [];
const asyncJobManager = new AsyncJobManager({
onJobComplete: async (jobId, text) => {
deliveries.push({ jobId, text });
},
});
const autoBackgroundBashTool = wrapToolWithMetaNotice(
new BashTool(
createTestToolSession(
testDir,
Settings.isolated({
"bash.autoBackground.enabled": true,
"bash.autoBackground.thresholdMs": 10,
}),
{
getSessionId: () => "test-session",
asyncJobManager,
},
),
),
);
const result = await autoBackgroundBashTool.execute("test-call-9-auto-running", {
command: "printf 'start\\n'; sleep 0.03; printf 'done\\n'",
});
expect(result.details?.async?.state).toBe("running");
expect(result.details?.async?.type).toBe("bash");
expect(getTextOutput(result)).toContain("Background job");
expect(getTextOutput(result)).toContain("start");
const jobId = result.details?.async?.jobId;
if (!jobId) {
throw new Error("expected an auto-backgrounded job id");
}
const runningJob = asyncJobManager.getJob(jobId);
expect(runningJob?.status).toBe("running");
await runningJob?.promise;
await asyncJobManager.drainDeliveries({ timeoutMs: 1 });
expect(deliveries).toHaveLength(1);
expect(deliveries[0]?.jobId).toBe(jobId);
expect(deliveries[0]?.text).toContain("done");
await asyncJobManager.dispose();
});
it("should background instead of timing out when auto-background wait exceeds the effective timeout", async () => {
const deliveries: Array<{ jobId: string; text: string }> = [];
const asyncJobManager = new AsyncJobManager({
onJobComplete: async (jobId, text) => {
deliveries.push({ jobId, text });
},
});
const autoBackgroundBashTool = wrapToolWithMetaNotice(
new BashTool(
createTestToolSession(
testDir,
Settings.isolated({
"bash.autoBackground.enabled": true,
"bash.autoBackground.thresholdMs": 60_000,
}),
{
getSessionId: () => "test-session",
asyncJobManager,
},
),
),
);
// Drive the effective timeout via the production clamp seam so the
// backgrounded job hits its timeout in ~0.1s instead of a real
// wall-clock second. The auto-background-on-timeout decision path is
// unchanged; we assert the timeout-notice prefix rather than the
// rounded seconds (it reads "0" here only because the seam
// deliberately undercuts the 1s production floor).
vi.spyOn(toolTimeouts, "clampTimeout").mockReturnValue(0.05);
const result = await autoBackgroundBashTool.execute("test-call-9-auto-timeout-background", {
command: "printf 'start\\n'; sleep 0.5; printf 'done\\n'",
timeout: 1,
});
expect(result.details?.timeoutSeconds).toBe(0.05);
expect(result.details?.async?.state).toBe("running");
expect(getTextOutput(result)).toContain("Background job");
const jobId = result.details?.async?.jobId;
if (!jobId) {
throw new Error("expected an auto-backgrounded job id");
}
const runningJob = asyncJobManager.getJob(jobId);
expect(runningJob?.status).toBe("running");
await runningJob?.promise;
await asyncJobManager.drainDeliveries({ timeoutMs: 1 });
expect(deliveries).toHaveLength(1);
expect(deliveries[0]?.jobId).toBe(jobId);
expect(deliveries[0]?.text).toContain("Command timed out after");
await asyncJobManager.dispose();
});
it("should surface clamped timeout in results", async () => {
const result = await bashTool.execute("test-call-timeout-clamp", { command: "echo ok", timeout: 7200 });
const output = getTextOutput(result);
expect(output).toContain("ok");
expect(output).toContain("Timeout clamped to 3600s (requested 7200s; allowed range 1-3600s).");
expect(result.details?.timeoutSeconds).toBe(3600);
expect(result.details?.requestedTimeoutSeconds).toBe(7200);
});
it("should respect timeout", async () => {
// Reduce the effective timeout through the production clamp seam; the
// real subprocess kill-on-timeout path is still exercised, just faster.
vi.spyOn(toolTimeouts, "clampTimeout").mockReturnValue(0.05);
await expect(bashTool.execute("test-call-10", { command: "sleep 5", timeout: 1 })).rejects.toThrow(
/timed out/i,
);
});
it("should abort and recover for subsequent commands", async () => {
const controller = new AbortController();
const started = Promise.withResolvers<void>();
const promise = bashTool.execute(
"test-call-10-abort",
{ command: "echo READY; sleep 60" },
controller.signal,
update => {
const text = update.content?.find(c => c.type === "text")?.text ?? "";
if (text.includes("READY")) started.resolve();
},
);
// Abort as soon as the command has emitted output (proving the shell is
// live), instead of blindly waiting a fixed beat for it to enter `sleep`.
await started.promise;
controller.abort("test abort");
await expect(promise).rejects.toThrow(/abort|cancel|timed out/i);
const result = await bashTool.execute("test-call-10-after-abort", { command: "echo ok" });
expect(getTextOutput(result)).toContain("ok");
}, 15_000);
it("should throw error when cwd does not exist", async () => {
const nonexistentCwd = "/this/directory/definitely/does/not/exist/12345";
const bashToolWithBadCwd = new BashTool(createTestToolSession(nonexistentCwd));
await expect(bashToolWithBadCwd.execute("test-call-11", { command: "echo test" })).rejects.toThrow(
/Working directory does not exist/,
);
});
it("should not pull cwd from a later-line `&&` when the command is multiline", async () => {
// Regression for #?: the `^cd ... && ...` extractor used `\s` and `[^&\\]`,
// which let the lazy match cross newlines and capture the whole script as the
// "cwd" when any later line contained `&&`. The model intended `cd` to run as
// part of a multiline script, not to relocate the entire command.
const command = [
"cd /this/directory/definitely/does/not/exist/12345",
"echo first-line",
"echo second && echo third",
].join("\n");
const result = await bashTool.execute("test-call-multiline-cd", { command });
const output = getTextOutput(result);
expect(output).toContain("first-line");
expect(output).toContain("second");
expect(output).toContain("third");
});
});
describe("JobTool", () => {
it("should wait for jobs and acknowledge deliveries to prevent race conditions", async () => {
const manager = new AsyncJobManager({
onJobComplete: async () => {},
});
const session = createTestToolSession(testDir, Settings.isolated({ "bash.autoBackground.enabled": true }), {
asyncJobManager: manager,
});
const jobTool = new JobTool(session);
const jobId = manager.register("bash", "test job", async () => "success");
// Job is running, call poll
const resultPromise = jobTool.execute("test-call-poll-1", { poll: [jobId] });
// Ensure poll finished
const result = await resultPromise;
expect(getTextOutput(result)).toContain("Completed");
// Wait for deliveries to be processed
await manager.drainDeliveries({ timeoutMs: 100 });
// If it correctly acknowledged, the delivery is suppressed.
expect(manager.hasPendingDeliveries()).toBe(false);
});
it("flags still-waiting polls and all-running snapshots as contextually useless", async () => {
const manager = new AsyncJobManager({
onJobComplete: async () => {},
});
const session = createTestToolSession(testDir, Settings.isolated({ "bash.autoBackground.enabled": true }), {
asyncJobManager: manager,
});
const jobTool = new JobTool(session);
const gate = Promise.withResolvers<string>();
const jobId = manager.register("bash", "long job", () => gate.promise);
// Poll cut short while the job is still running: a pure "still
// waiting" snapshot carries no information once consumed.
const controller = new AbortController();
const pollPromise = jobTool.execute("test-call-useless-poll", { poll: [jobId] }, controller.signal);
controller.abort();
const polled = await pollPromise;
expect(polled.useless).toBe(true);
// A list snapshot showing only running jobs is equally uneventful.
const listed = await jobTool.execute("test-call-useless-list", { list: true });
expect(listed.useless).toBe(true);
// Once the job settles, the result is informative — flag absent.
gate.resolve("done");
const settled = await jobTool.execute("test-call-useless-settled", { poll: [jobId] });
expect(getTextOutput(settled)).toContain("Completed");
expect(settled.useless).toBeUndefined();
// Nothing left to wait for: noise once consumed.
const idle = await jobTool.execute("test-call-useless-idle", {});
expect(getTextOutput(idle)).toContain("No running background jobs");
expect(idle.useless).toBe(true);
// A poll naming unknown ids found nothing — equally uneventful.
const missing = await jobTool.execute("test-call-useless-missing", { poll: ["no-such-job"] });
expect(getTextOutput(missing)).toContain("No matching jobs found");
expect(missing.useless).toBe(true);
});
});
describe("search tool", () => {
it("should include filename when searching a single file", async () => {
const testFile = path.join(testDir, "example.txt");
fs.writeFileSync(testFile, "first line\nmatch line\nlast line");
const result = await searchTool.execute("test-call-11", {
pattern: "match",
path: testFile,
});
const output = getTextOutput(result);
expect(output).not.toContain("# example.txt");
// PI_EDIT_VARIANT=replace in beforeEach disables hashlines; expect line-number mode
expect(output).toMatch(/\*2\|match line/);
});
it("flags a zero-match search as contextually useless", async () => {
fs.writeFileSync(path.join(testDir, "plain.txt"), "nothing interesting here\n");
const result = await searchTool.execute("test-call-useless-search", {
pattern: "ZZZ_NO_SUCH_TOKEN_999",
path: testDir,
});
expect(getTextOutput(result)).toContain("No matches found");
expect(result.useless).toBe(true);
});
it("flags a zero-match search useless even when it reports missing paths", async () => {
fs.writeFileSync(path.join(testDir, "plain.txt"), "nothing interesting here\n");
const result = await searchTool.execute("test-call-useless-search-warn", {
pattern: "ZZZ_NO_SUCH_TOKEN_999",
path: `${testDir}; ${path.join(testDir, "missing-file.txt")}`,
});
expect(getTextOutput(result)).toContain("Skipped missing paths");
expect(result.useless).toBe(true);
});
it("should accept wildcard patterns in paths", async () => {
fs.writeFileSync(path.join(testDir, "schema-review-alpha.test.ts"), "review target\n");
fs.writeFileSync(path.join(testDir, "schema-review-beta.test.ts"), "review target\n");
fs.writeFileSync(path.join(testDir, "schema-other.test.ts"), "review target\n");
const result = await searchTool.execute("test-call-11-path-glob", {
pattern: "review target",
path: `${testDir}/schema-review-*.test.ts`,
});
const output = getTextOutput(result);
expect(output).toContain("# schema-review-alpha.test.ts");
expect(output).toContain("# schema-review-beta.test.ts");
expect(output).not.toContain("schema-other.test.ts");
expect(result.details?.fileCount).toBe(2);
});
it("should accept nested wildcard filters in paths", async () => {
const packageDir = path.join(testDir, "node_modules", ".bun");
const aiDir = path.join(packageDir, "ai@6.0.119+build123", "node_modules", "ai");
const nestedDir = path.join(aiDir, "nested");
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(aiDir, "root.ts"), "providerOptions\n");
fs.writeFileSync(path.join(nestedDir, "child.d.ts"), "providerOptions\n");
fs.writeFileSync(path.join(aiDir, "ignore.js"), "providerOptions\n");
fs.writeFileSync(path.join(testDir, "outside.ts"), "providerOptions\n");
const result = await searchTool.execute("test-call-11-path-and-glob", {
pattern: "providerOptions",
path: `${packageDir}/ai@6.0.119+*/node_modules/ai/**/*.{d.ts,ts}`,
gitignore: false,
});
const output = getTextOutput(result);
expect(output).toContain("## root.ts");
expect(output).toContain("## child.d.ts");
expect(output).not.toContain("ignore.js");
expect(output).not.toContain("outside.ts");
expect(result.details?.fileCount).toBe(2);
});
it("should include configured context lines", async () => {
const testFile = path.join(testDir, "context.txt");
const content = ["before", "match one", "after", "middle", "match two", "after two"].join("\n");
fs.writeFileSync(testFile, content);
const contextSettings = Settings.isolated({ "grep.contextBefore": 1, "grep.contextAfter": 1 });
const contextSearchTool = wrapToolWithMetaNotice(
new GrepTool(createTestToolSession(testDir, contextSettings)),
);
const result = await contextSearchTool.execute("test-call-12", {
pattern: "match",
path: testFile,
});
const output = getTextOutput(result);
expect(output).not.toContain("# context.txt");
expect(output).toMatch(/ 1\|before/);
expect(output).toMatch(/\*2\|match one/);
expect(output).toMatch(/ 3\|after/);
expect(output).toMatch(/\*5\|match two/);
});
it("inserts a gap separator between non-contiguous match blocks", async () => {
const testFile = path.join(testDir, "gaps.txt");
const lines = Array.from({ length: 10 }, (_, idx) => (idx === 0 || idx === 5 ? "match" : `filler ${idx}`));
fs.writeFileSync(testFile, lines.join("\n"));
const noContextSettings = Settings.isolated({ "grep.contextBefore": 0, "grep.contextAfter": 0 });
const noContextSearchTool = wrapToolWithMetaNotice(
new GrepTool(createTestToolSession(testDir, noContextSettings)),
);
const result = await noContextSearchTool.execute("test-call-12-gap", {
pattern: "match",
path: testFile,
});
const output = getTextOutput(result);
expect(output).toMatch(/\*1\|match\n\.\.\.\n\*6\|match/);
});
it("should paginate files via the skip parameter", async () => {
const skipDir = path.join(testDir, "skip-dir");
fs.mkdirSync(skipDir, { recursive: true });
for (let i = 1; i <= 4; i++) {
fs.writeFileSync(path.join(skipDir, `file-${i}.txt`), `needle ${i}`);
}
const first = await searchTool.execute("test-call-12-skip-first", {
pattern: "needle",
path: skipDir,
});
expect(first.details?.fileCount).toBe(4);
const second = await searchTool.execute("test-call-12-skip-page", {
pattern: "needle",
path: skipDir,
skip: 2,
});
const secondOutput = getTextOutput(second);
expect(second.details?.fileCount).toBe(2);
expect(secondOutput).not.toContain("# file-1.txt");
expect(secondOutput).not.toContain("# file-2.txt");
expect(secondOutput).toContain("# file-3.txt");
expect(secondOutput).toContain("# file-4.txt");
});
it("respects the case parameter (case-sensitive by default, case-insensitive if false)", async () => {
const caseFile = path.join(testDir, "case.txt");
fs.writeFileSync(caseFile, "Hello World\nhello world\n");
// 1. By default, search is case-sensitive (only matches the lowercase pattern "hello")
const defaultResult = await searchTool.execute("test-case-default", {
pattern: "hello",
path: caseFile,
});
expect(defaultResult.details?.matchCount).toBe(1);
// 2. With case: true, search is case-sensitive (only matches "hello")
const sensitiveResult = await searchTool.execute("test-case-sensitive", {
pattern: "hello",
path: caseFile,
case: true,
});
expect(sensitiveResult.details?.matchCount).toBe(1);
// 3. With case: false, search is case-insensitive (matches both "Hello World" and "hello world")
const insensitiveResult = await searchTool.execute("test-case-insensitive", {
pattern: "hello",
path: caseFile,
case: false,
});
expect(insensitiveResult.details?.matchCount).toBe(2);
});
it("should group multi-file matches", async () => {
for (let i = 1; i <= 3; i++) {
fs.writeFileSync(path.join(testDir, `file-${i}.txt`), `needle in file ${i}\nextra needle ${i}`);
}
fs.writeFileSync(path.join(testDir, "dominant.txt"), "needle a\nneedle b\nneedle c\nneedle d");
const result = await searchTool.execute("test-call-13-round-robin", {
pattern: "needle",
path: testDir,
});
const output = getTextOutput(result);
expect(output).toContain("# file-1.txt");
expect(output).toContain("# file-2.txt");
expect(output).toContain("# file-3.txt");
expect(output).toContain("# dominant.txt");
expect(output).not.toContain("# .");
expect(output).not.toContain("Result limit reached");
expect(result.details?.fileCount).toBe(4);
expect(result.details?.matchCount).toBe(10);
});
it("should not repeat file headings for multiple matches per file", async () => {
fs.writeFileSync(path.join(testDir, "alpha.txt"), "needle a1\nneedle a2\nneedle a3");
fs.writeFileSync(path.join(testDir, "beta.txt"), "needle b1\nneedle b2\nneedle b3");
const result = await searchTool.execute("test-call-14-grouped-headings", {
pattern: "needle",
path: testDir,
});
const output = getTextOutput(result);
const alphaHeadings = output.match(/# alpha\.txt/g)?.length ?? 0;
const betaHeadings = output.match(/# beta\.txt/g)?.length ?? 0;
expect(alphaHeadings).toBe(1);
expect(betaHeadings).toBe(1);
expect(result.details?.fileMatches).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "alpha.txt", count: 3 }),
expect.objectContaining({ path: "beta.txt", count: 3 }),
]),
);
});
it("should group files under directory headings", async () => {
const nestedDir = path.join(testDir, "packages", "ai");
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, "CHANGELOG.md"), "Claude Opus\n");
fs.writeFileSync(path.join(nestedDir, "models.json"), '{ "name": "Claude Opus" }\n');
const result = await searchTool.execute("test-call-15-directory-headings", {
pattern: "Claude Opus",
path: testDir,
});
const output = getTextOutput(result);
expect(output).toContain("# packages/ai");
expect(output).toContain("## CHANGELOG.md");
expect(output).toContain("## models.json");
expect(result.details?.fileCount).toBeGreaterThanOrEqual(2);
});
it("should respect .gitignore by default", async () => {
const scenarioDir = path.join(testDir, "grep-gitignore-default");
fs.mkdirSync(path.join(scenarioDir, ".git"), { recursive: true });
fs.writeFileSync(path.join(scenarioDir, ".gitignore"), "ignored.txt\n");
fs.writeFileSync(path.join(scenarioDir, "ignored.txt"), "needle ignored\n");
fs.writeFileSync(path.join(scenarioDir, "kept.txt"), "needle kept\n");
const result = await searchTool.execute("test-call-15-gitignore-default", {
pattern: "needle",
path: scenarioDir,
});
const output = getTextOutput(result);
expect(output).toContain("kept.txt");
expect(output).not.toContain("ignored.txt");
expect(result.details?.fileCount).toBe(1);
expect(result.details?.matchCount).toBe(1);
});
it("should include ignored files when gitignore is false", async () => {
const scenarioDir = path.join(testDir, "grep-gitignore-off");
fs.mkdirSync(path.join(scenarioDir, ".git"), { recursive: true });
fs.writeFileSync(path.join(scenarioDir, ".gitignore"), "ignored.txt\n");
fs.writeFileSync(path.join(scenarioDir, "ignored.txt"), "needle ignored\n");
const result = await searchTool.execute("test-call-16-gitignore-off", {
pattern: "needle",
path: scenarioDir,
gitignore: false,
});
const output = getTextOutput(result);
expect(output).toContain("ignored.txt");
expect(result.details?.fileCount).toBe(1);
expect(result.details?.matchCount).toBe(1);
});
it("should ignore FIFOs when searching a directory with gitignore disabled", async () => {
const scenarioDir = path.join(testDir, "grep-fifo-dir");
fs.mkdirSync(scenarioDir, { recursive: true });
fs.writeFileSync(path.join(scenarioDir, "match.txt"), "needle kept\n");
const fifoPath = path.join(scenarioDir, "blocked.fifo");
if (!createFifoOrSkip(fifoPath)) {
return;
}
const result = await searchTool.execute("test-call-16-fifo-dir", {
pattern: "needle",
path: scenarioDir,
gitignore: false,
});
const output = getTextOutput(result);
expect(output).toContain("match.txt");
expect(output).toContain("needle kept");
expect(output).not.toContain("blocked.fifo");
expect(output).not.toContain("## blocked.fifo");
expect(result.details?.fileCount).toBe(1);
expect(result.details?.matchCount).toBe(1);
});
it("should cap distinct files and surface pagination", async () => {
const limitDir = path.join(testDir, "file-limit-dir");
fs.mkdirSync(limitDir, { recursive: true });
const totalFiles = DEFAULT_FILE_LIMIT + 4;
for (let i = 1; i <= totalFiles; i++) {
fs.writeFileSync(path.join(limitDir, `f-${String(i).padStart(2, "0")}.txt`), `needle ${i}`);
}
const result = await searchTool.execute("test-call-14-file-limit", {
pattern: "needle",
path: limitDir,
});
const output = getTextOutput(result);
expect(result.details?.fileCount).toBe(DEFAULT_FILE_LIMIT);
expect(result.details?.matchCount).toBe(DEFAULT_FILE_LIMIT);
expect(result.details?.fileLimitReached).toBe(DEFAULT_FILE_LIMIT);
expect(output).toContain(`Showing files 1-${DEFAULT_FILE_LIMIT} of ${totalFiles}`);
expect(output).toContain(`Use skip=${DEFAULT_FILE_LIMIT}`);
});
it("should cap matches per file in multi-file scopes", async () => {
const concDir = path.join(testDir, "concentration-dir");
fs.mkdirSync(concDir, { recursive: true });
const hotMatches = MULTI_FILE_PER_FILE_MATCHES + 30;
fs.writeFileSync(
path.join(concDir, "hot.txt"),
Array.from({ length: hotMatches }, (_, i) => `needle ${i + 1}`).join("\n"),
);
fs.writeFileSync(path.join(concDir, "cool.txt"), "needle cool");
const result = await searchTool.execute("test-call-14-per-file-cap", {
pattern: "needle",
path: concDir,
});
const hotCount = result.details?.fileMatches?.find(entry => entry.path.endsWith("hot.txt"))?.count ?? 0;
expect(hotCount).toBe(MULTI_FILE_PER_FILE_MATCHES);
expect(result.details?.perFileLimitReached).toBe(MULTI_FILE_PER_FILE_MATCHES);
});
it("should let a single-file scope exceed the multi-file per-file cap", async () => {
const single = path.join(testDir, "single-file.txt");
const count = MULTI_FILE_PER_FILE_MATCHES + 30;
fs.writeFileSync(single, Array.from({ length: count }, (_, i) => `needle ${i + 1}`).join("\n"));
const result = await searchTool.execute("test-call-14-single-file-cap", {
pattern: "needle",
path: single,
});
expect(result.details?.matchCount).toBe(count);
expect(result.details?.fileLimitReached).toBeUndefined();
expect(result.details?.perFileLimitReached).toBeUndefined();
});
});
describe("find tool", () => {
it("should return a single file when given a file path", async () => {
const testFile = path.join(testDir, "single.txt");
fs.writeFileSync(testFile, "single");
const result = await findTool.execute("test-call-13a", {
path: testFile,
});
const outputLines = getTextOutput(result)
.split("\n")
.map(line => line.trim())
.filter(Boolean);
expect(outputLines).toEqual(["single.txt"]);
});
it("should include hidden files that are not gitignored", async () => {
const hiddenDir = path.join(testDir, ".secret");
fs.mkdirSync(hiddenDir);
fs.writeFileSync(path.join(hiddenDir, "hidden.txt"), "hidden");
fs.writeFileSync(path.join(testDir, "visible.txt"), "visible");
const result = await findTool.execute("test-call-13", {
path: `${testDir}/**/*.txt`,
hidden: true,
});
const files = (result.details?.files ?? []).slice().sort();
expect(files).toContain("visible.txt");
expect(files).toContain(".secret/hidden.txt");
});
it("should respect .gitignore", async () => {
fs.mkdirSync(path.join(testDir, ".git"));
fs.writeFileSync(path.join(testDir, ".gitignore"), "ignored.txt\n");
fs.writeFileSync(path.join(testDir, "ignored.txt"), "ignored");
fs.writeFileSync(path.join(testDir, "kept.txt"), "kept");
const result = await findTool.execute("test-call-14", {
path: `${testDir}/**/*.txt`,
});
const output = getTextOutput(result);
expect(output).toContain("kept.txt");
expect(output).not.toContain("ignored.txt");
});
it("should sort exact recursive filename matches by mtime", async () => {
const olderDir = path.join(testDir, "a");
const newerDir = path.join(testDir, "z");
fs.mkdirSync(olderDir, { recursive: true });
fs.mkdirSync(newerDir, { recursive: true });
const olderFile = path.join(olderDir, "auth-actions.spec.ts");
const newerFile = path.join(newerDir, "auth-actions.spec.ts");
fs.writeFileSync(olderFile, "old\n");
fs.writeFileSync(newerFile, "new\n");
const olderTime = new Date(Date.now() - 60_000);
const newerTime = new Date();
fs.utimesSync(olderFile, olderTime, olderTime);
fs.utimesSync(newerFile, newerTime, newerTime);
const result = await findTool.execute("test-call-14b", {
path: `${testDir}/**/auth-actions.spec.ts`,
});
expect(result.details?.files).toEqual(["z/auth-actions.spec.ts", "a/auth-actions.spec.ts"]);
});
it("should render nested glob results relative to the session cwd", async () => {
const nestedDir = path.join(testDir, "apps", "daemon", "src", "telemetry");
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, "daemon-telemetry.ts"), "telemetry\n");
const result = await findTool.execute("test-call-14c", {
path: "apps/daemon/src/**/daemon-telemetry.ts",
});
expect(result.details?.files).toEqual(["apps/daemon/src/telemetry/daemon-telemetry.ts"]);
});
it("should not double-prefix multi-pattern results under a shared base", async () => {
const daemonDir = path.join(testDir, "apps", "daemon", "src");
const clientDir = path.join(testDir, "apps", "client", "src");
fs.mkdirSync(daemonDir, { recursive: true });
fs.mkdirSync(clientDir, { recursive: true });
fs.writeFileSync(path.join(daemonDir, "daemon.ts"), "daemon\n");
fs.writeFileSync(path.join(clientDir, "client.ts"), "client\n");
const result = await findTool.execute("test-call-14e", {
path: JSON.stringify(["apps/daemon/src/**/*.ts", "apps/client/src/**/*.ts"]),
});
const files = (result.details?.files ?? []).slice().sort();
expect(files).toEqual(["apps/client/src/client.ts", "apps/daemon/src/daemon.ts"]);
});
it("should not disable gitignore after an ignored broad hidden-file search finds no matches", async () => {
fs.mkdirSync(path.join(testDir, ".git"));
fs.writeFileSync(path.join(testDir, ".gitignore"), ".env*\nignored-generated/\n");
fs.writeFileSync(path.join(testDir, ".env.local"), "SECRET=value\n");
fs.mkdirSync(path.join(testDir, "ignored-generated"));
fs.writeFileSync(path.join(testDir, "ignored-generated", ".env.generated"), "SECRET=value\n");
const startedAt = performance.now();
const result = await findTool.execute("test-call-14d", {
path: "**/.env*",
});
const elapsedMs = performance.now() - startedAt;
const output = getTextOutput(result);
expect(output).toContain("No files found matching pattern");
expect(output).not.toContain(".env.local");
expect(output).not.toContain(".env.generated");
expect(elapsedMs).toBeLessThan(1000);
});
it("should return directories alongside files with a trailing slash", async () => {
fs.mkdirSync(path.join(testDir, "pkg"));
fs.mkdirSync(path.join(testDir, "pkg", "nested"));
fs.writeFileSync(path.join(testDir, "pkg", "file.txt"), "f");
fs.writeFileSync(path.join(testDir, "pkg", "nested", "deep.txt"), "d");
const result = await findTool.execute("test-call-14f", {
path: `${testDir}/pkg/**/*`,
});
const files = (result.details?.files ?? []).slice().sort();
expect(files).toEqual(["pkg/file.txt", "pkg/nested/", "pkg/nested/deep.txt"]);
});
it("should match a directory by glob and emit it with trailing slash", async () => {
fs.mkdirSync(path.join(testDir, "alpha", "tests"), { recursive: true });
fs.mkdirSync(path.join(testDir, "beta", "tests"), { recursive: true });
fs.writeFileSync(path.join(testDir, "alpha", "tests", "a.ts"), "a");
const result = await findTool.execute("test-call-14g", {
path: `${testDir}/**/tests`,
});
const files = (result.details?.files ?? []).slice().sort();
expect(files).toEqual(["alpha/tests/", "beta/tests/"]);
});
it("should not recurse into subdirectories for a single-star glob like dir/*", async () => {
const dir = path.join(testDir, "shallow");
const sub = path.join(dir, "sub");
fs.mkdirSync(sub, { recursive: true });
fs.writeFileSync(path.join(dir, "top.tsx"), "t");
fs.writeFileSync(path.join(sub, "nested.tsx"), "n");
const result = await findTool.execute("test-call-14h", {
path: `${dir}/*.tsx`,
});
const files = (result.details?.files ?? []).slice().sort();
expect(files).toEqual(["shallow/top.tsx"]);
});
});
});
describe("edit tool CRLF handling", () => {
let testDir: string;
let editTool: EditTool;
let originalEditVariant: string | undefined;
beforeEach(() => {
// Force replace mode for edit tool tests using old_text/new_text
originalEditVariant = Bun.env.PI_EDIT_VARIANT;
Bun.env.PI_EDIT_VARIANT = "replace";
testDir = path.join(os.tmpdir(), `coding-agent-crlf-test-${Snowflake.next()}`);
fs.mkdirSync(testDir, { recursive: true });
editTool = new EditTool(createTestToolSession(testDir));
});
afterEach(() => {
removeSyncWithRetries(testDir);
// Restore original edit variant
if (originalEditVariant === undefined) {
delete Bun.env.PI_EDIT_VARIANT;
} else {
Bun.env.PI_EDIT_VARIANT = originalEditVariant;
}
});
it("should match LF old_text against CRLF file content", async () => {
const testFile = path.join(testDir, "crlf-test.txt");
fs.writeFileSync(testFile, "line one\r\nline two\r\nline three\r\n");
const result = await editTool.execute("test-crlf-1", {
path: testFile,
edits: [{ old_text: "line two\n", new_text: "replaced line\n" }],
});
expect(getTextOutput(result)).toContain("Successfully replaced");
});
it("should preserve CRLF line endings after edit", async () => {
const testFile = path.join(testDir, "crlf-preserve.txt");
fs.writeFileSync(testFile, "first\r\nsecond\r\nthird\r\n");
await editTool.execute("test-crlf-2", {
path: testFile,
edits: [{ old_text: "second\n", new_text: "REPLACED\n" }],
});
const content = await Bun.file(testFile).text();
expect(content).toBe("first\r\nREPLACED\r\nthird\r\n");
});
it("should preserve LF line endings for LF files", async () => {
const testFile = path.join(testDir, "lf-preserve.txt");
fs.writeFileSync(testFile, "first\nsecond\nthird\n");
await editTool.execute("test-lf-1", {
path: testFile,
edits: [{ old_text: "second\n", new_text: "REPLACED\n" }],
});
const content = await Bun.file(testFile).text();
expect(content).toBe("first\nREPLACED\nthird\n");
});
it("should detect duplicates across CRLF/LF variants", async () => {
const testFile = path.join(testDir, "mixed-endings.txt");
fs.writeFileSync(testFile, "hello\r\nworld\r\n---\r\nhello\nworld\n");
await expect(
editTool.execute("test-crlf-dup", {
path: testFile,
edits: [{ old_text: "hello\nworld\n", new_text: "replaced\n" }],
}),
).rejects.toThrow(/Found 2 occurrences/);
});
// TODO: CRLF preservation broken by LSP formatting - fix later
it.skip("should preserve UTF-8 BOM after edit", async () => {
const testFile = path.join(testDir, "bom-test.txt");
fs.writeFileSync(testFile, "\uFEFFfirst\r\nsecond\r\nthird\r\n");
await editTool.execute("test-bom", {
path: testFile,
edits: [{ old_text: "second\n", new_text: "REPLACED\n" }],
});
const content = await Bun.file(testFile).text();
expect(content).toBe("\uFEFFfirst\r\nREPLACED\r\nthird\r\n");
});
});