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"); }); });