summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-read-file
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tool-read-file')
-rw-r--r--packages/tool-read-file/package.json20
-rw-r--r--packages/tool-read-file/src/extension.ts32
-rw-r--r--packages/tool-read-file/src/read-file.test.ts688
-rw-r--r--packages/tool-read-file/src/read-file.ts330
-rw-r--r--packages/tool-read-file/tsconfig.json8
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" }]
}