Skip to content
34 changes: 26 additions & 8 deletions lib/agent/__tests__/buildAgentTools.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import { describe, it, expect } from "vitest";
import { buildAgentTools } from "@/lib/agent/buildAgentTools";

const EXPECTED_TOOL_NAMES = [
"bash",
"read",
"write",
"edit",
"grep",
"glob",
"todo_write",
"web_fetch",
] as const;

describe("buildAgentTools", () => {
it("returns a tools record keyed by tool name", () => {
it("returns a tools record with all 8 leaf tools registered", () => {
const tools = buildAgentTools();
expect(tools).toHaveProperty("bash");
expect(typeof tools.bash).toBe("object");
for (const name of EXPECTED_TOOL_NAMES) {
expect(tools).toHaveProperty(name);
}
});

it("each tool has an inputSchema, description, and execute", () => {
const tools = buildAgentTools();
expect(tools.bash.inputSchema).toBeDefined();
expect(tools.bash.description).toBeDefined();
expect(typeof tools.bash.execute).toBe("function");
it("each tool exposes the AI SDK shape (description + inputSchema + execute)", () => {
const tools = buildAgentTools() as Record<
string,
{ description?: unknown; inputSchema?: unknown; execute?: unknown }
>;
for (const name of EXPECTED_TOOL_NAMES) {
const t = tools[name]!;
expect(typeof t.description).toBe("string");
expect(t.inputSchema).toBeDefined();
expect(typeof t.execute).toBe("function");
}
});
});
29 changes: 24 additions & 5 deletions lib/agent/buildAgentTools.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
import { bashTool } from "@/lib/agent/tools/bashTool";
import { readFileTool } from "@/lib/agent/tools/readFileTool";
import { writeFileTool } from "@/lib/agent/tools/writeFileTool";
import { editFileTool } from "@/lib/agent/tools/editFileTool";
import { grepTool } from "@/lib/agent/tools/grepTool";
import { globTool } from "@/lib/agent/tools/globTool";
import { todoWriteTool } from "@/lib/agent/tools/todoWriteTool";
import { webFetchTool } from "@/lib/agent/tools/webFetchTool";

/**
* Factory for the full agent tool set passed into `streamText({ tools })`.
* Each tool reads its sandbox handle + recoup creds from `experimental_context`
* at execute time — the factory takes no arguments because the tools are
* stateless modulo that context.
*
* Slim PR 4 exposes only `bash`. The remaining sandbox tools (`read`,
* `write`, `grep`, `glob`, `todo`, `task`, `ask_user_question`, `skill`,
* `fetch`) port in follow-up PRs and slot into this record one-by-one
* without changing the factory signature.
* Currently ships 8 leaf tools:
* - bash, read, write, edit, grep, glob (sandbox / file ops)
* - todo_write (planning surface; stateless, echoes the list back)
* - web_fetch (HTTP via curl inside the sandbox)
*
* Composite tools (`task` subagent, `ask_user_question` UI part,
* `skill` skill discovery) port in a follow-up PR — they require
* subagent context plumbing / UI rendering / skill discovery infra
* that isn't in api today.
*/
export function buildAgentTools() {
return {
bash: bashTool(),
bash: bashTool,
read: readFileTool,
write: writeFileTool,
edit: editFileTool,
grep: grepTool,
glob: globTool,
todo_write: todoWriteTool,
web_fetch: webFetchTool,
};
}

Expand Down
14 changes: 7 additions & 7 deletions lib/agent/tools/__tests__/bashTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe("bashTool.execute", () => {
});
vi.mocked(connectVercel).mockResolvedValue(sandbox as never);

const tool = bashTool();
const tool = bashTool;
const result = await tool.execute!({ command: "ls" }, {
experimental_context: baseContext,
} as never);
Expand Down Expand Up @@ -64,7 +64,7 @@ describe("bashTool.execute", () => {
});
vi.mocked(connectVercel).mockResolvedValue(sandbox as never);

const tool = bashTool();
const tool = bashTool;
const result = (await tool.execute!({ command: "find ." }, {
experimental_context: baseContext,
} as never)) as { truncated?: boolean };
Expand All @@ -83,7 +83,7 @@ describe("bashTool.execute", () => {
});
vi.mocked(connectVercel).mockResolvedValue(sandbox as never);

const tool = bashTool();
const tool = bashTool;
await tool.execute!({ command: "ls", cwd: "apps/web" }, {
experimental_context: baseContext,
} as never);
Expand All @@ -107,7 +107,7 @@ describe("bashTool.execute", () => {
});
vi.mocked(connectVercel).mockResolvedValue(sandbox as never);

const tool = bashTool();
const tool = bashTool;
await tool.execute!({ command: "curl example.com" }, {
experimental_context: { ...baseContext, recoupOrgId: "org-uuid" },
} as never);
Expand All @@ -121,7 +121,7 @@ describe("bashTool.execute", () => {
});
vi.mocked(connectVercel).mockResolvedValue(sandbox as never);

const tool = bashTool();
const tool = bashTool;
const result = (await tool.execute!({ command: "npm run dev", detached: true }, {
experimental_context: baseContext,
} as never)) as { success: boolean; stdout: string };
Expand All @@ -134,7 +134,7 @@ describe("bashTool.execute", () => {
const sandbox = makeSandbox({ execDetached: undefined });
vi.mocked(connectVercel).mockResolvedValue(sandbox as never);

const tool = bashTool();
const tool = bashTool;
const result = (await tool.execute!({ command: "npm run dev", detached: true }, {
experimental_context: baseContext,
} as never)) as { success: boolean; stderr: string };
Expand All @@ -148,7 +148,7 @@ describe("bashTool.execute", () => {
});
vi.mocked(connectVercel).mockResolvedValue(sandbox as never);

const tool = bashTool();
const tool = bashTool;
await tool.execute!({ command: "npm run dev", detached: true }, {
experimental_context: { ...baseContext, recoupOrgId: "org-uuid" },
} as never);
Expand Down
86 changes: 86 additions & 0 deletions lib/agent/tools/__tests__/editFileTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { editFileTool } from "@/lib/agent/tools/editFileTool";
import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel";

vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({
connectVercel: vi.fn(),
}));

const ctx = { sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" } };

function makeSandbox(initialContent: string) {
let stored = initialContent;
return {
workingDirectory: "/sandbox/mono",
readFile: vi.fn(async () => stored),
writeFile: vi.fn(async (_path: string, content: string) => {
stored = content;
}),
getStored: () => stored,
};
}

beforeEach(() => vi.clearAllMocks());

describe("editFileTool", () => {
it("replaces a unique oldString once and reports the startLine", async () => {
const sb = makeSandbox("line one\nold value\nline three");
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const tool = editFileTool;
const result = (await tool.execute!(
{ filePath: "a.txt", oldString: "old value", newString: "new value" },
{ experimental_context: ctx } as never,
)) as { success: boolean; replacements: number; startLine: number };
expect(result.success).toBe(true);
expect(result.replacements).toBe(1);
expect(result.startLine).toBe(2);
expect(sb.getStored()).toBe("line one\nnew value\nline three");
});

it("rejects when oldString === newString (no-op)", async () => {
const sb = makeSandbox("anything");
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const tool = editFileTool;
const result = (await tool.execute!({ filePath: "a.txt", oldString: "x", newString: "x" }, {
experimental_context: ctx,
} as never)) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toMatch(/must be different/);
});

it("rejects when oldString is not in the file", async () => {
const sb = makeSandbox("hello world");
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const tool = editFileTool;
const result = (await tool.execute!(
{ filePath: "a.txt", oldString: "missing", newString: "other" },
{ experimental_context: ctx } as never,
)) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toMatch(/not found/);
});

it("rejects ambiguous edits (multiple matches without replaceAll)", async () => {
const sb = makeSandbox("foo\nfoo\nbar");
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const tool = editFileTool;
const result = (await tool.execute!({ filePath: "a.txt", oldString: "foo", newString: "baz" }, {
experimental_context: ctx,
} as never)) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toMatch(/2 times/);
});

it("replaces all occurrences when replaceAll:true", async () => {
const sb = makeSandbox("foo bar foo baz foo");
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const tool = editFileTool;
const result = (await tool.execute!(
{ filePath: "a.txt", oldString: "foo", newString: "qux", replaceAll: true },
{ experimental_context: ctx } as never,
)) as { success: boolean; replacements: number };
expect(result.success).toBe(true);
expect(result.replacements).toBe(3);
expect(sb.getStored()).toBe("qux bar qux baz qux");
});
});
97 changes: 97 additions & 0 deletions lib/agent/tools/__tests__/globTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { globTool } from "@/lib/agent/tools/globTool";
import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel";

vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({
connectVercel: vi.fn(),
}));

const ctx = { sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" } };

function makeSandbox(exec: ReturnType<typeof vi.fn>) {
return { workingDirectory: "/sandbox/mono", exec };
}

beforeEach(() => vi.clearAllMocks());

describe("globTool", () => {
it("parses `mtime\\tsize\\tpath` output into structured file entries", async () => {
// Two files, newest first (sort already happens server-side in the command).
const sb = makeSandbox(
vi.fn().mockResolvedValue({
success: true,
exitCode: 0,
stdout:
"1700000000.0\t512\t/sandbox/mono/src/index.ts\n1699999000.5\t256\t/sandbox/mono/src/util.ts",
stderr: "",
truncated: false,
}),
);
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const tool = globTool;
const result = (await tool.execute!({ pattern: "**/*.ts" }, {
experimental_context: ctx,
} as never)) as {
success: boolean;
count: number;
files: Array<{ path: string; size: number; modifiedAt: string }>;
};
expect(result.success).toBe(true);
expect(result.count).toBe(2);
expect(result.files[0]?.path).toBe("src/index.ts");
expect(result.files[0]?.size).toBe(512);
expect(typeof result.files[0]?.modifiedAt).toBe("string"); // ISO
});

it("emits a recursive find (no -maxdepth) for `**/*.ts`", async () => {
const sb = makeSandbox(
vi.fn().mockResolvedValue({
success: true,
exitCode: 0,
stdout: "",
stderr: "",
truncated: false,
}),
);
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const tool = globTool;
await tool.execute!({ pattern: "**/*.ts" }, { experimental_context: ctx } as never);
const cmd = sb.exec.mock.calls[0]?.[0] as string;
expect(cmd).not.toContain("-maxdepth");
});

it("emits -maxdepth 1 for a bare `*.json` pattern (no recursion)", async () => {
const sb = makeSandbox(
vi.fn().mockResolvedValue({
success: true,
exitCode: 0,
stdout: "",
stderr: "",
truncated: false,
}),
);
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const tool = globTool;
await tool.execute!({ pattern: "*.json" }, { experimental_context: ctx } as never);
expect(sb.exec.mock.calls[0]?.[0]).toMatch(/-maxdepth\s+1/);
});

it("returns success:false on non-1 exit codes", async () => {
const sb = makeSandbox(
vi.fn().mockResolvedValue({
success: false,
exitCode: 2,
stdout: "err",
stderr: "",
truncated: false,
}),
);
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const tool = globTool;
const result = (await tool.execute!({ pattern: "**/*.ts" }, {
experimental_context: ctx,
} as never)) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toMatch(/exit 2/);
});
});
Loading
Loading