clanker-agent/packages/coding-agent/test/tools.test.ts
Harivansh Rathi 0250f72976 move pi-mono into companion-cloud as apps/companion-os
- Copy all pi-mono source into apps/companion-os/
- Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases
- Update deploy-staging.yml to build pi from source (bun compile) before Docker build
- Add apps/companion-os/** to path triggers
- No more cross-repo dispatch needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:22:50 -08:00

689 lines
23 KiB
TypeScript

import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { bashTool, createBashTool } from "../src/core/tools/bash.js";
import { editTool } from "../src/core/tools/edit.js";
import { findTool } from "../src/core/tools/find.js";
import { grepTool } from "../src/core/tools/grep.js";
import { lsTool } from "../src/core/tools/ls.js";
import { readTool } from "../src/core/tools/read.js";
import { writeTool } from "../src/core/tools/write.js";
import * as shellModule from "../src/utils/shell.js";
// 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") || ""
);
}
describe("Coding Agent Tools", () => {
let testDir: string;
beforeEach(() => {
// Create a unique temporary directory for each test
testDir = join(tmpdir(), `coding-agent-test-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
// Clean up test directory
rmSync(testDir, { recursive: true, force: true });
});
describe("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();
});
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 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2Z0AAAAASUVORK5CYII=";
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);
});
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)).toContain("Successfully wrote");
expect(getTextOutput(result)).toContain(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,
oldText: "world",
newText: "testing",
});
expect(getTextOutput(result)).toContain("Successfully replaced");
expect(result.details).toBeDefined();
expect(result.details.diff).toBeDefined();
expect(typeof result.details.diff).toBe("string");
expect(result.details.diff).toContain("testing");
});
it("should fail if text not found", async () => {
const testFile = join(testDir, "edit-test.txt");
const originalContent = "Hello, world!";
writeFileSync(testFile, originalContent);
await expect(
editTool.execute("test-call-6", {
path: testFile,
oldText: "nonexistent",
newText: "testing",
}),
).rejects.toThrow(/Could not find the exact text/);
});
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,
oldText: "foo",
newText: "bar",
}),
).rejects.toThrow(/Found 3 occurrences/);
});
});
describe("bash tool", () => {
it("should execute simple commands", async () => {
const result = await bashTool.execute("test-call-8", {
command: "echo 'test output'",
});
expect(getTextOutput(result)).toContain("test output");
expect(result.details).toBeUndefined();
});
it("should handle command errors", async () => {
await expect(
bashTool.execute("test-call-9", { command: "exit 1" }),
).rejects.toThrow(/(Command failed|code 1)/);
});
it("should respect timeout", async () => {
await expect(
bashTool.execute("test-call-10", { command: "sleep 5", timeout: 1 }),
).rejects.toThrow(/timed out/i);
});
it("should 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 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");
});
});
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");
});
});
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");
});
});
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/");
});
});
});
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,
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 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,
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,
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,
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,
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,
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,
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,
oldText: "hello world",
newText: "replaced",
}),
).rejects.toThrow(/Found 2 occurrences/);
});
});
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,
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,
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,
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,
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,
oldText: "second\n",
newText: "REPLACED\n",
});
const content = readFileSync(testFile, "utf-8");
expect(content).toBe("\uFEFFfirst\r\nREPLACED\r\nthird\r\n");
});
});