Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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