diff options
Diffstat (limited to 'packages/tool-read-file')
| -rw-r--r-- | packages/tool-read-file/package.json | 20 | ||||
| -rw-r--r-- | packages/tool-read-file/src/extension.ts | 32 | ||||
| -rw-r--r-- | packages/tool-read-file/src/read-file.test.ts | 688 | ||||
| -rw-r--r-- | packages/tool-read-file/src/read-file.ts | 330 | ||||
| -rw-r--r-- | packages/tool-read-file/tsconfig.json | 8 |
5 files changed, 539 insertions, 539 deletions
diff --git a/packages/tool-read-file/package.json b/packages/tool-read-file/package.json index ffb974d..5a08095 100644 --- a/packages/tool-read-file/package.json +++ b/packages/tool-read-file/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/tool-read-file", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/exec-backend": "workspace:*" - } + "name": "@dispatch/tool-read-file", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/exec-backend": "workspace:*" + } } diff --git a/packages/tool-read-file/src/extension.ts b/packages/tool-read-file/src/extension.ts index 5a0b7c5..273727f 100644 --- a/packages/tool-read-file/src/extension.ts +++ b/packages/tool-read-file/src/extension.ts @@ -3,20 +3,20 @@ import type { Extension } from "@dispatch/kernel"; import { createReadFileTool } from "./read-file.js"; export const extension: Extension = { - manifest: { - id: "tool-read-file", - name: "Read File Tool", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { fs: true }, - contributes: { tools: ["read_file"] }, - // Host activates exec-backend first → host.getService at activation is safe. - dependsOn: ["exec-backend"], - }, - activate(host) { - const resolveBackend = host.getService(execBackendHandle); - host.defineTool(createReadFileTool({ resolveBackend, workdir: process.cwd() })); - }, + manifest: { + id: "tool-read-file", + name: "Read File Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { fs: true }, + contributes: { tools: ["read_file"] }, + // Host activates exec-backend first → host.getService at activation is safe. + dependsOn: ["exec-backend"], + }, + activate(host) { + const resolveBackend = host.getService(execBackendHandle); + host.defineTool(createReadFileTool({ resolveBackend, workdir: process.cwd() })); + }, }; diff --git a/packages/tool-read-file/src/read-file.test.ts b/packages/tool-read-file/src/read-file.test.ts index bac5902..785769a 100644 --- a/packages/tool-read-file/src/read-file.test.ts +++ b/packages/tool-read-file/src/read-file.test.ts @@ -5,25 +5,25 @@ import { localExecBackend } from "@dispatch/exec-backend"; import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - createReadFileTool, - formatDirectoryEntries, - renderLines, - sliceLines, - validateArgs, + createReadFileTool, + formatDirectoryEntries, + renderLines, + sliceLines, + validateArgs, } from "./read-file.js"; function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } /** @@ -32,355 +32,355 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { * real fs edge is exercised, matching the constitution's strict-core rule. */ function makeTool(workdir: string) { - return createReadFileTool({ resolveBackend: () => localExecBackend, workdir }); + return createReadFileTool({ resolveBackend: () => localExecBackend, workdir }); } let workdir: string; beforeEach(async () => { - workdir = await mkdtemp(join(tmpdir(), "tool-read-file-test-")); + workdir = await mkdtemp(join(tmpdir(), "tool-read-file-test-")); }); afterEach(async () => { - await rm(workdir, { recursive: true, force: true }); + await rm(workdir, { recursive: true, force: true }); }); describe("validateArgs", () => { - it("returns validated args for valid input", () => { - const result = validateArgs({ path: "foo.txt" }); - expect(result).toEqual({ path: "foo.txt", offset: 1, limit: 500 }); - }); - - it("parses offset and limit", () => { - const result = validateArgs({ path: "foo.txt", offset: 5, limit: 10 }); - expect(result).toEqual({ path: "foo.txt", offset: 5, limit: 10 }); - }); - - it("clamps limit to hard cap of 5000", () => { - const result = validateArgs({ path: "foo.txt", limit: 99999 }); - expect(result).toEqual({ path: "foo.txt", offset: 1, limit: 5000 }); - }); - - it("returns error for null args", () => { - const result = validateArgs(null); - expect(result).toHaveProperty("error"); - }); - - it("returns error for missing path", () => { - const result = validateArgs({}); - expect(result).toHaveProperty("error"); - }); - - it("returns error for non-string path", () => { - const result = validateArgs({ path: 123 }); - expect(result).toHaveProperty("error"); - }); - - it("returns error for invalid offset", () => { - const result = validateArgs({ path: "foo.txt", offset: -1 }); - expect(result).toHaveProperty("error"); - }); - - it("returns error for invalid limit", () => { - const result = validateArgs({ path: "foo.txt", limit: 0 }); - expect(result).toHaveProperty("error"); - }); + it("returns validated args for valid input", () => { + const result = validateArgs({ path: "foo.txt" }); + expect(result).toEqual({ path: "foo.txt", offset: 1, limit: 500 }); + }); + + it("parses offset and limit", () => { + const result = validateArgs({ path: "foo.txt", offset: 5, limit: 10 }); + expect(result).toEqual({ path: "foo.txt", offset: 5, limit: 10 }); + }); + + it("clamps limit to hard cap of 5000", () => { + const result = validateArgs({ path: "foo.txt", limit: 99999 }); + expect(result).toEqual({ path: "foo.txt", offset: 1, limit: 5000 }); + }); + + it("returns error for null args", () => { + const result = validateArgs(null); + expect(result).toHaveProperty("error"); + }); + + it("returns error for missing path", () => { + const result = validateArgs({}); + expect(result).toHaveProperty("error"); + }); + + it("returns error for non-string path", () => { + const result = validateArgs({ path: 123 }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for invalid offset", () => { + const result = validateArgs({ path: "foo.txt", offset: -1 }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for invalid limit", () => { + const result = validateArgs({ path: "foo.txt", limit: 0 }); + expect(result).toHaveProperty("error"); + }); }); describe("sliceLines", () => { - it("returns all lines with offset=1, limit=500", () => { - const content = "line1\nline2\nline3"; - const result = sliceLines(content, 1, 500); - expect(result.lines).toEqual(["line1", "line2", "line3"]); - expect(result.totalLines).toBe(3); - }); - - it("slices with offset", () => { - const content = "line1\nline2\nline3\nline4"; - const result = sliceLines(content, 2, 2); - expect(result.lines).toEqual(["line2", "line3"]); - expect(result.totalLines).toBe(4); - }); - - it("handles offset beyond content", () => { - const content = "line1\nline2"; - const result = sliceLines(content, 10, 5); - expect(result.lines).toEqual([]); - expect(result.totalLines).toBe(2); - }); - - it("handles single line (no newline)", () => { - const content = "only line"; - const result = sliceLines(content, 1, 10); - expect(result.lines).toEqual(["only line"]); - expect(result.totalLines).toBe(1); - }); + it("returns all lines with offset=1, limit=500", () => { + const content = "line1\nline2\nline3"; + const result = sliceLines(content, 1, 500); + expect(result.lines).toEqual(["line1", "line2", "line3"]); + expect(result.totalLines).toBe(3); + }); + + it("slices with offset", () => { + const content = "line1\nline2\nline3\nline4"; + const result = sliceLines(content, 2, 2); + expect(result.lines).toEqual(["line2", "line3"]); + expect(result.totalLines).toBe(4); + }); + + it("handles offset beyond content", () => { + const content = "line1\nline2"; + const result = sliceLines(content, 10, 5); + expect(result.lines).toEqual([]); + expect(result.totalLines).toBe(2); + }); + + it("handles single line (no newline)", () => { + const content = "only line"; + const result = sliceLines(content, 1, 10); + expect(result.lines).toEqual(["only line"]); + expect(result.totalLines).toBe(1); + }); }); describe("renderLines", () => { - it("renders lines with 1-indexed line numbers", () => { - const result = renderLines(["a", "b", "c"], 1); - expect(result).toBe("1: a\n2: b\n3: c"); - }); - - it("renders with custom offset", () => { - const result = renderLines(["x", "y"], 10); - expect(result).toBe("10: x\n11: y"); - }); + it("renders lines with 1-indexed line numbers", () => { + const result = renderLines(["a", "b", "c"], 1); + expect(result).toBe("1: a\n2: b\n3: c"); + }); + + it("renders with custom offset", () => { + const result = renderLines(["x", "y"], 10); + expect(result).toBe("10: x\n11: y"); + }); }); describe("formatDirectoryEntries", () => { - it("lists directory entries sorted with trailing slash on subdirectories", () => { - const entries = [ - { name: "zebra.txt", isDirectory: false }, - { name: "alpha", isDirectory: true }, - { name: "readme.md", isDirectory: false }, - { name: "beta", isDirectory: true }, - ]; - const result = formatDirectoryEntries(entries, "mydir"); - expect(result).toBe("alpha/\nbeta/\nreadme.md\nzebra.txt"); - }); - - it("returns empty-directory message for an empty dir", () => { - const result = formatDirectoryEntries([], "empty-dir"); - expect(result).toBe("(empty directory: empty-dir)"); - }); - - it("handles mixed files and directories with same name sorting", () => { - const entries = [ - { name: "b", isDirectory: false }, - { name: "a", isDirectory: true }, - ]; - const result = formatDirectoryEntries(entries, "."); - expect(result).toBe("a/\nb"); - }); + it("lists directory entries sorted with trailing slash on subdirectories", () => { + const entries = [ + { name: "zebra.txt", isDirectory: false }, + { name: "alpha", isDirectory: true }, + { name: "readme.md", isDirectory: false }, + { name: "beta", isDirectory: true }, + ]; + const result = formatDirectoryEntries(entries, "mydir"); + expect(result).toBe("alpha/\nbeta/\nreadme.md\nzebra.txt"); + }); + + it("returns empty-directory message for an empty dir", () => { + const result = formatDirectoryEntries([], "empty-dir"); + expect(result).toBe("(empty directory: empty-dir)"); + }); + + it("handles mixed files and directories with same name sorting", () => { + const entries = [ + { name: "b", isDirectory: false }, + { name: "a", isDirectory: true }, + ]; + const result = formatDirectoryEntries(entries, "."); + expect(result).toBe("a/\nb"); + }); }); describe("createReadFileTool", () => { - it("reads a real temp file", async () => { - const filePath = join(workdir, "hello.txt"); - await writeFile(filePath, "hello\nworld\n", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "hello.txt" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("1: hello"); - expect(result.content).toContain("2: world"); - }); - - it("respects offset and limit", async () => { - const filePath = join(workdir, "lines.txt"); - await writeFile(filePath, "a\nb\nc\nd\ne\n", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "lines.txt", offset: 2, limit: 2 }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("2: b\n3: c"); - }); - - it("returns error for missing file", async () => { - const tool = makeTool(workdir); - const result = await tool.execute({ path: "nonexistent.txt" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("not found"); - }); - - it("returns empty-file content for empty file", async () => { - const filePath = join(workdir, "empty.txt"); - await writeFile(filePath, "", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "empty.txt" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("empty file"); - expect(result.content).toContain("empty.txt"); - }); - - it("returns error for offset beyond file length", async () => { - const filePath = join(workdir, "short.txt"); - await writeFile(filePath, "one\n", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "short.txt", offset: 100 }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("exceeds total lines"); - }); - - it("never throws on bad input (always returns ToolResult)", async () => { - const tool = makeTool(workdir); - - const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; - for (const input of inputs) { - const result = await tool.execute(input, stubCtx()); - expect(result).toHaveProperty("content"); - expect(typeof result.content).toBe("string"); - } - }); - - it("concurrencySafe is true", () => { - const tool = makeTool(workdir); - expect(tool.concurrencySafe).toBe(true); - }); - - it("has correct name and parameters shape", () => { - const tool = makeTool(workdir); - expect(tool.name).toBe("read_file"); - expect(tool.parameters.type).toBe("object"); - expect(tool.parameters.required).toEqual(["path"]); - expect(tool.parameters.properties?.path?.type).toBe("string"); - }); - - it("reads file under ctx.cwd when set (not baked workdir)", async () => { - const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); - try { - const filePath = join(ctxDir, "ctx-file.txt"); - await writeFile(filePath, "from ctx cwd", "utf8"); - - const tool = makeTool(workdir); // baked workdir is different - const result = await tool.execute({ path: "ctx-file.txt" }, stubCtx({ cwd: ctxDir })); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("1: from ctx cwd"); - } finally { - await rm(ctxDir, { recursive: true, force: true }); - } - }); - - it("falls back to baked workdir when ctx.cwd is omitted", async () => { - const filePath = join(workdir, "baked-file.txt"); - await writeFile(filePath, "from baked workdir", "utf8"); - - const tool = makeTool(workdir); - const ctx = stubCtx(); - // Ensure cwd is undefined - expect(ctx.cwd).toBeUndefined(); - const result = await tool.execute({ path: "baked-file.txt" }, ctx); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("1: from baked workdir"); - }); - - it("lists directory entries sorted with trailing slash on subdirectories", async () => { - await mkdir(join(workdir, "subdir")); - await writeFile(join(workdir, "zebra.txt"), "z", "utf8"); - await writeFile(join(workdir, "alpha.txt"), "a", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "." }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("alpha.txt\nsubdir/\nzebra.txt"); - }); - - it("returns empty-directory message for an empty dir", async () => { - await mkdir(join(workdir, "empty-dir")); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "empty-dir" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("(empty directory: empty-dir)"); - }); - - it("reads a file unchanged (regression: line numbers + offset/limit)", async () => { - await writeFile(join(workdir, "regression.txt"), "a\nb\nc\nd\ne\n", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "regression.txt", offset: 2, limit: 3 }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("2: b\n3: c\n4: d"); - }); - - it("returns not-found for a nonexistent path", async () => { - const tool = makeTool(workdir); - const result = await tool.execute({ path: "nonexistent-path" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("not found"); - }); - - it("routes fs calls through resolveBackend(ctx.computerId) (transport seam)", async () => { - // A fake backend records what it is asked to do. Proves the tool programs - // against the ExecBackend surface (not node:fs) and that the resolver is - // invoked with ctx.computerId — the SSH seam. No real fs involved. - let statCalls = 0; - let readFileCalls = 0; - let readdirCalls = 0; - let receivedComputerId: string | undefined = "__sentinel__"; - const fakeBackend = { - spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), - readFile: async (path: string) => { - readFileCalls++; - expect(path).toContain("seam.txt"); - return "fake-line-1\nfake-line-2"; - }, - writeFile: async () => {}, - stat: async (path: string) => { - statCalls++; - expect(path).toContain("seam.txt"); - return { isFile: true, isDirectory: false }; - }, - readdir: async () => { - readdirCalls++; - return []; - }, - exists: async () => true, - } as const; - - const tool = createReadFileTool({ - resolveBackend: (computerId) => { - receivedComputerId = computerId; - return fakeBackend; - }, - workdir, - }); - const result = await tool.execute({ path: "seam.txt" }, stubCtx({ computerId: "prod-ssh" })); - - expect(receivedComputerId).toBe("prod-ssh"); - expect(statCalls).toBe(1); - expect(readFileCalls).toBe(1); - expect(readdirCalls).toBe(0); - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("1: fake-line-1\n2: fake-line-2"); - }); - - it("resolves the local backend when ctx.computerId is undefined (backward compat)", async () => { - // computerId undefined → resolver returns localExecBackend → real fs. - const tool = createReadFileTool({ resolveBackend: () => localExecBackend, workdir }); - const filePath = join(workdir, "compat.txt"); - await writeFile(filePath, "real fs via backend\n", "utf8"); - - const result = await tool.execute({ path: "compat.txt" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("1: real fs via backend"); - }); - - it("preserves ENOENT .code branch through the backend (fake backend throws)", async () => { - const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { - code: "ENOENT", - }); - const fakeBackend = { - spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), - readFile: async () => "unused", - writeFile: async () => {}, - stat: async () => { - throw enoent; - }, - readdir: async () => [], - exists: async () => false, - } as const; - - const tool = createReadFileTool({ resolveBackend: () => fakeBackend, workdir }); - const result = await tool.execute({ path: "ghost.txt" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toBe('Error: File "ghost.txt" not found.'); - }); + it("reads a real temp file", async () => { + const filePath = join(workdir, "hello.txt"); + await writeFile(filePath, "hello\nworld\n", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "hello.txt" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("1: hello"); + expect(result.content).toContain("2: world"); + }); + + it("respects offset and limit", async () => { + const filePath = join(workdir, "lines.txt"); + await writeFile(filePath, "a\nb\nc\nd\ne\n", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "lines.txt", offset: 2, limit: 2 }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("2: b\n3: c"); + }); + + it("returns error for missing file", async () => { + const tool = makeTool(workdir); + const result = await tool.execute({ path: "nonexistent.txt" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("not found"); + }); + + it("returns empty-file content for empty file", async () => { + const filePath = join(workdir, "empty.txt"); + await writeFile(filePath, "", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "empty.txt" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("empty file"); + expect(result.content).toContain("empty.txt"); + }); + + it("returns error for offset beyond file length", async () => { + const filePath = join(workdir, "short.txt"); + await writeFile(filePath, "one\n", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "short.txt", offset: 100 }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("exceeds total lines"); + }); + + it("never throws on bad input (always returns ToolResult)", async () => { + const tool = makeTool(workdir); + + const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; + for (const input of inputs) { + const result = await tool.execute(input, stubCtx()); + expect(result).toHaveProperty("content"); + expect(typeof result.content).toBe("string"); + } + }); + + it("concurrencySafe is true", () => { + const tool = makeTool(workdir); + expect(tool.concurrencySafe).toBe(true); + }); + + it("has correct name and parameters shape", () => { + const tool = makeTool(workdir); + expect(tool.name).toBe("read_file"); + expect(tool.parameters.type).toBe("object"); + expect(tool.parameters.required).toEqual(["path"]); + expect(tool.parameters.properties?.path?.type).toBe("string"); + }); + + it("reads file under ctx.cwd when set (not baked workdir)", async () => { + const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); + try { + const filePath = join(ctxDir, "ctx-file.txt"); + await writeFile(filePath, "from ctx cwd", "utf8"); + + const tool = makeTool(workdir); // baked workdir is different + const result = await tool.execute({ path: "ctx-file.txt" }, stubCtx({ cwd: ctxDir })); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("1: from ctx cwd"); + } finally { + await rm(ctxDir, { recursive: true, force: true }); + } + }); + + it("falls back to baked workdir when ctx.cwd is omitted", async () => { + const filePath = join(workdir, "baked-file.txt"); + await writeFile(filePath, "from baked workdir", "utf8"); + + const tool = makeTool(workdir); + const ctx = stubCtx(); + // Ensure cwd is undefined + expect(ctx.cwd).toBeUndefined(); + const result = await tool.execute({ path: "baked-file.txt" }, ctx); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("1: from baked workdir"); + }); + + it("lists directory entries sorted with trailing slash on subdirectories", async () => { + await mkdir(join(workdir, "subdir")); + await writeFile(join(workdir, "zebra.txt"), "z", "utf8"); + await writeFile(join(workdir, "alpha.txt"), "a", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "." }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("alpha.txt\nsubdir/\nzebra.txt"); + }); + + it("returns empty-directory message for an empty dir", async () => { + await mkdir(join(workdir, "empty-dir")); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "empty-dir" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("(empty directory: empty-dir)"); + }); + + it("reads a file unchanged (regression: line numbers + offset/limit)", async () => { + await writeFile(join(workdir, "regression.txt"), "a\nb\nc\nd\ne\n", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "regression.txt", offset: 2, limit: 3 }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("2: b\n3: c\n4: d"); + }); + + it("returns not-found for a nonexistent path", async () => { + const tool = makeTool(workdir); + const result = await tool.execute({ path: "nonexistent-path" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("not found"); + }); + + it("routes fs calls through resolveBackend(ctx.computerId) (transport seam)", async () => { + // A fake backend records what it is asked to do. Proves the tool programs + // against the ExecBackend surface (not node:fs) and that the resolver is + // invoked with ctx.computerId — the SSH seam. No real fs involved. + let statCalls = 0; + let readFileCalls = 0; + let readdirCalls = 0; + let receivedComputerId: string | undefined = "__sentinel__"; + const fakeBackend = { + spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), + readFile: async (path: string) => { + readFileCalls++; + expect(path).toContain("seam.txt"); + return "fake-line-1\nfake-line-2"; + }, + writeFile: async () => {}, + stat: async (path: string) => { + statCalls++; + expect(path).toContain("seam.txt"); + return { isFile: true, isDirectory: false }; + }, + readdir: async () => { + readdirCalls++; + return []; + }, + exists: async () => true, + } as const; + + const tool = createReadFileTool({ + resolveBackend: (computerId) => { + receivedComputerId = computerId; + return fakeBackend; + }, + workdir, + }); + const result = await tool.execute({ path: "seam.txt" }, stubCtx({ computerId: "prod-ssh" })); + + expect(receivedComputerId).toBe("prod-ssh"); + expect(statCalls).toBe(1); + expect(readFileCalls).toBe(1); + expect(readdirCalls).toBe(0); + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("1: fake-line-1\n2: fake-line-2"); + }); + + it("resolves the local backend when ctx.computerId is undefined (backward compat)", async () => { + // computerId undefined → resolver returns localExecBackend → real fs. + const tool = createReadFileTool({ resolveBackend: () => localExecBackend, workdir }); + const filePath = join(workdir, "compat.txt"); + await writeFile(filePath, "real fs via backend\n", "utf8"); + + const result = await tool.execute({ path: "compat.txt" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("1: real fs via backend"); + }); + + it("preserves ENOENT .code branch through the backend (fake backend throws)", async () => { + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { + code: "ENOENT", + }); + const fakeBackend = { + spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), + readFile: async () => "unused", + writeFile: async () => {}, + stat: async () => { + throw enoent; + }, + readdir: async () => [], + exists: async () => false, + } as const; + + const tool = createReadFileTool({ resolveBackend: () => fakeBackend, workdir }); + const result = await tool.execute({ path: "ghost.txt" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toBe('Error: File "ghost.txt" not found.'); + }); }); diff --git a/packages/tool-read-file/src/read-file.ts b/packages/tool-read-file/src/read-file.ts index b88c241..b77f63c 100644 --- a/packages/tool-read-file/src/read-file.ts +++ b/packages/tool-read-file/src/read-file.ts @@ -6,68 +6,68 @@ const DEFAULT_LIMIT = 500; const HARD_CAP = 5000; interface ValidatedArgs { - readonly path: string; - readonly offset: number; - readonly limit: number; + readonly path: string; + readonly offset: number; + readonly limit: number; } /** Pure: validate and coerce args from the model. */ export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object." }; - } - const obj = args as Record<string, unknown>; - - const rawPath = obj.path; - if (typeof rawPath !== "string" || rawPath.length === 0) { - return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; - } - - let offset = 1; - if (obj.offset !== undefined) { - const n = Number(obj.offset); - if (!Number.isFinite(n) || n < 1) { - return { error: 'Error: Invalid "offset" parameter (must be a positive integer).' }; - } - offset = Math.floor(n); - } - - let limit = DEFAULT_LIMIT; - if (obj.limit !== undefined) { - const n = Number(obj.limit); - if (!Number.isFinite(n) || n < 1) { - return { error: 'Error: Invalid "limit" parameter (must be a positive integer).' }; - } - limit = Math.min(Math.floor(n), HARD_CAP); - } else { - limit = Math.min(limit, HARD_CAP); - } - - return { path: rawPath, offset, limit }; + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record<string, unknown>; + + const rawPath = obj.path; + if (typeof rawPath !== "string" || rawPath.length === 0) { + return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; + } + + let offset = 1; + if (obj.offset !== undefined) { + const n = Number(obj.offset); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "offset" parameter (must be a positive integer).' }; + } + offset = Math.floor(n); + } + + let limit = DEFAULT_LIMIT; + if (obj.limit !== undefined) { + const n = Number(obj.limit); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "limit" parameter (must be a positive integer).' }; + } + limit = Math.min(Math.floor(n), HARD_CAP); + } else { + limit = Math.min(limit, HARD_CAP); + } + + return { path: rawPath, offset, limit }; } /** Pure: slice lines from content (1-indexed offset). */ export function sliceLines( - content: string, - offset: number, - limit: number, + content: string, + offset: number, + limit: number, ): { readonly lines: readonly string[]; readonly totalLines: number } { - const allLines = content.split("\n"); - const totalLines = allLines.length; - const start = offset - 1; // convert to 0-indexed - const sliced = allLines.slice(start, start + limit); - return { lines: sliced, totalLines }; + const allLines = content.split("\n"); + const totalLines = allLines.length; + const start = offset - 1; // convert to 0-indexed + const sliced = allLines.slice(start, start + limit); + return { lines: sliced, totalLines }; } /** Pure: render lines into a string with line numbers. */ export function renderLines(lines: readonly string[], offset: number): string { - return lines.map((line, i) => `${offset + i}: ${line}`).join("\n"); + return lines.map((line, i) => `${offset + i}: ${line}`).join("\n"); } /** A directory entry with its type. */ export interface DirEntry { - readonly name: string; - readonly isDirectory: boolean; + readonly name: string; + readonly isDirectory: boolean; } /** @@ -75,11 +75,11 @@ export interface DirEntry { * Subdirectories get a trailing `/`. Empty input returns an empty-directory message. */ export function formatDirectoryEntries(entries: readonly DirEntry[], dirPath: string): string { - if (entries.length === 0) { - return `(empty directory: ${dirPath})`; - } - const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); - return sorted.map((e) => (e.isDirectory ? `${e.name}/` : e.name)).join("\n"); + if (entries.length === 0) { + return `(empty directory: ${dirPath})`; + } + const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); + return sorted.map((e) => (e.isDirectory ? `${e.name}/` : e.name)).join("\n"); } /** @@ -94,120 +94,120 @@ export function formatDirectoryEntries(entries: readonly DirEntry[], dirPath: st * injected so the tool is testable; `execute` prefers `ctx.cwd` when present. */ export function createReadFileTool(deps: { - readonly resolveBackend: ExecBackendResolver; - readonly workdir?: string; + readonly resolveBackend: ExecBackendResolver; + readonly workdir?: string; }): ToolContract { - const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; - - return { - name: "read_file", - description: - "Read the contents of a file or list a directory's contents. " + - "For files, returns lines with 1-indexed line numbers. " + - "Supports offset/limit for reading specific sections of large files. " + - "For directories, returns sorted entries with subdirectories suffixed by /.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Path to the file, relative to the working directory.", - }, - offset: { - type: "number", - description: "1-indexed start line number (default: 1).", - default: 1, - }, - limit: { - type: "number", - description: "Maximum number of lines to return (default: 500, hard cap: 5000).", - default: 500, - }, - }, - required: ["path"], - }, - concurrencySafe: true, - async execute(args: unknown, ctx): Promise<ToolResult> { - const validated = validateArgs(args); - if ("error" in validated) { - return { content: validated.error, isError: true }; - } - - const { path: relPath, offset, limit } = validated; - - const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; - if (effectiveBase === undefined) { - return { - content: - "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", - isError: true, - }; - } - const resolvedPath = resolve(effectiveBase, relPath); - - const backend: ExecBackend = deps.resolveBackend(ctx.computerId); - - // Stat to determine if this is a file or directory. - let pathStat: StatResult; - try { - pathStat = await backend.stat(resolvedPath); - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return { content: `Error: File "${relPath}" not found.`, isError: true }; - } - return { - content: `Error reading path: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - // Directory listing branch. backend.readdir already returns - // {name, isDirectory}[] entries, so no per-entry collapse is needed. - if (pathStat.isDirectory) { - let entries: readonly DirEntry[]; - try { - entries = await backend.readdir(resolvedPath); - } catch (err: unknown) { - return { - content: `Error reading directory: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - return { content: formatDirectoryEntries(entries, relPath) }; - } - - // File branch — read the file. - let content: string; - try { - content = await backend.readFile(resolvedPath); - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return { content: `Error: File "${relPath}" not found.`, isError: true }; - } - return { - content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - // Handle empty file. - if (content.length === 0) { - return { content: `(empty file: ${relPath})` }; - } - - // Apply offset/limit line slicing. - const { lines, totalLines } = sliceLines(content, offset, limit); - - if (offset > totalLines) { - return { - content: `Error: offset ${offset} exceeds total lines (${totalLines}) in "${relPath}".`, - isError: true, - }; - } - - return { content: renderLines(lines, offset) }; - }, - }; + const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; + + return { + name: "read_file", + description: + "Read the contents of a file or list a directory's contents. " + + "For files, returns lines with 1-indexed line numbers. " + + "Supports offset/limit for reading specific sections of large files. " + + "For directories, returns sorted entries with subdirectories suffixed by /.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file, relative to the working directory.", + }, + offset: { + type: "number", + description: "1-indexed start line number (default: 1).", + default: 1, + }, + limit: { + type: "number", + description: "Maximum number of lines to return (default: 500, hard cap: 5000).", + default: 500, + }, + }, + required: ["path"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx): Promise<ToolResult> { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + + const { path: relPath, offset, limit } = validated; + + const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; + if (effectiveBase === undefined) { + return { + content: + "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", + isError: true, + }; + } + const resolvedPath = resolve(effectiveBase, relPath); + + const backend: ExecBackend = deps.resolveBackend(ctx.computerId); + + // Stat to determine if this is a file or directory. + let pathStat: StatResult; + try { + pathStat = await backend.stat(resolvedPath); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { content: `Error: File "${relPath}" not found.`, isError: true }; + } + return { + content: `Error reading path: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + // Directory listing branch. backend.readdir already returns + // {name, isDirectory}[] entries, so no per-entry collapse is needed. + if (pathStat.isDirectory) { + let entries: readonly DirEntry[]; + try { + entries = await backend.readdir(resolvedPath); + } catch (err: unknown) { + return { + content: `Error reading directory: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + return { content: formatDirectoryEntries(entries, relPath) }; + } + + // File branch — read the file. + let content: string; + try { + content = await backend.readFile(resolvedPath); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { content: `Error: File "${relPath}" not found.`, isError: true }; + } + return { + content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + // Handle empty file. + if (content.length === 0) { + return { content: `(empty file: ${relPath})` }; + } + + // Apply offset/limit line slicing. + const { lines, totalLines } = sliceLines(content, offset, limit); + + if (offset > totalLines) { + return { + content: `Error: offset ${offset} exceeds total lines (${totalLines}) in "${relPath}".`, + isError: true, + }; + } + + return { content: renderLines(lines, offset) }; + }, + }; } diff --git a/packages/tool-read-file/tsconfig.json b/packages/tool-read-file/tsconfig.json index 30cdc4d..b790281 100644 --- a/packages/tool-read-file/tsconfig.json +++ b/packages/tool-read-file/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }] } |
