summaryrefslogtreecommitdiffhomepage
path: root/packages/core
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core')
-rw-r--r--packages/core/package.json21
-rw-r--r--packages/core/src/agent/agent.ts142
-rw-r--r--packages/core/src/index.ts9
-rw-r--r--packages/core/src/llm/provider.ts9
-rw-r--r--packages/core/src/tools/list-files.ts38
-rw-r--r--packages/core/src/tools/read-file.ts33
-rw-r--r--packages/core/src/tools/registry.ts35
-rw-r--r--packages/core/src/tools/write-file.ts33
-rw-r--r--packages/core/src/types/index.ts53
-rw-r--r--packages/core/tests/agent/agent.test.ts244
-rw-r--r--packages/core/tests/tools/list-files.test.ts41
-rw-r--r--packages/core/tests/tools/read-file.test.ts36
-rw-r--r--packages/core/tests/tools/registry.test.ts50
-rw-r--r--packages/core/tests/tools/write-file.test.ts45
-rw-r--r--packages/core/tsconfig.json10
-rw-r--r--packages/core/vitest.config.ts14
16 files changed, 813 insertions, 0 deletions
diff --git a/packages/core/package.json b/packages/core/package.json
new file mode 100644
index 0000000..3741041
--- /dev/null
+++ b/packages/core/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@dispatch/core",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "ai": "^4.0.0",
+ "@ai-sdk/openai-compatible": "^0.2.0",
+ "zod": "^3.23.0"
+ },
+ "devDependencies": {
+ "@types/bun": "latest"
+ }
+}
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
new file mode 100644
index 0000000..a20a4ce
--- /dev/null
+++ b/packages/core/src/agent/agent.ts
@@ -0,0 +1,142 @@
+import type { CoreMessage } from "ai";
+import { streamText } from "ai";
+import { createProvider } from "../llm/provider.js";
+import { createToolRegistry } from "../tools/registry.js";
+import type {
+ AgentConfig,
+ AgentEvent,
+ AgentStatus,
+ ChatMessage,
+ ToolCall,
+ ToolResult,
+} from "../types/index.js";
+
+function toCoreMessages(messages: ChatMessage[]): CoreMessage[] {
+ const result: CoreMessage[] = [];
+ for (const msg of messages) {
+ if (msg.role === "user") {
+ result.push({ role: "user", content: msg.content });
+ } else if (msg.role === "assistant") {
+ result.push({ role: "assistant", content: msg.content });
+ }
+ }
+ return result;
+}
+
+function formatError(err: unknown, config: AgentConfig): string {
+ const context = `[model=${config.model}, baseURL=${config.baseURL}]`;
+
+ if (err instanceof Error) {
+ const cause = err.cause ? ` | cause: ${JSON.stringify(err.cause)}` : "";
+ // AI SDK errors often have statusCode, responseBody, or url properties
+ const extras: string[] = [];
+ const errRecord = err as unknown as Record<string, unknown>;
+ if ("statusCode" in errRecord) extras.push(`status=${errRecord.statusCode}`);
+ if ("url" in errRecord) extras.push(`url=${errRecord.url}`);
+ if ("responseBody" in errRecord) extras.push(`body=${JSON.stringify(errRecord.responseBody)}`);
+ if ("responseHeaders" in errRecord)
+ extras.push(`headers=${JSON.stringify(errRecord.responseHeaders)}`);
+
+ const detail = extras.length > 0 ? ` (${extras.join(", ")})` : "";
+ return `${err.message}${detail}${cause} ${context}`;
+ }
+
+ return `${String(err)} ${context}`;
+}
+
+export class Agent {
+ status: AgentStatus = "idle";
+ messages: ChatMessage[] = [];
+
+ private config: AgentConfig;
+
+ constructor(config: AgentConfig) {
+ this.config = config;
+ }
+
+ async *run(userMessage: string): AsyncGenerator<AgentEvent> {
+ this.status = "running";
+ yield { type: "status", status: "running" };
+
+ this.messages.push({ role: "user", content: userMessage });
+
+ const registry = createToolRegistry(this.config.tools);
+ const providerFactory = createProvider({
+ apiKey: this.config.apiKey,
+ baseURL: this.config.baseURL,
+ });
+
+ try {
+ const result = streamText({
+ model: providerFactory(this.config.model),
+ system: this.config.systemPrompt,
+ messages: toCoreMessages(this.messages),
+ tools: registry.getAISDKTools(),
+ maxSteps: 10,
+ });
+
+ let fullText = "";
+ const toolCalls: ToolCall[] = [];
+ const toolResults: ToolResult[] = [];
+
+ for await (const event of result.fullStream) {
+ if (event.type === "text-delta") {
+ fullText += event.textDelta;
+ yield { type: "text-delta", delta: event.textDelta };
+ } else if (event.type === "tool-call") {
+ const toolCall: ToolCall = {
+ id: event.toolCallId,
+ name: event.toolName,
+ arguments: event.args as Record<string, unknown>,
+ };
+ toolCalls.push(toolCall);
+ yield { type: "tool-call", toolCall };
+ } else if (event.type === "error") {
+ const errorMsg = formatError(event.error, this.config);
+ yield { type: "error", error: errorMsg };
+ this.status = "error";
+ yield { type: "status", status: "error" };
+ return;
+ }
+ }
+
+ // Tool results are available from completed steps after streaming.
+ // The generic TOOLS type resolves to never[] at compile time, so
+ // we cast through unknown to access the runtime shape.
+ const steps = await result.steps;
+ for (const step of steps) {
+ const stepToolResults = step.toolResults as unknown as Array<{
+ toolCallId: string;
+ result: unknown;
+ }>;
+ for (const tr of stepToolResults) {
+ const toolResult: ToolResult = {
+ toolCallId: tr.toolCallId,
+ result: typeof tr.result === "string" ? tr.result : JSON.stringify(tr.result),
+ isError: false,
+ };
+ toolResults.push(toolResult);
+ yield { type: "tool-result", toolResult };
+ }
+ }
+
+ const assistantMessage: ChatMessage = {
+ role: "assistant",
+ content: fullText,
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
+ toolResults: toolResults.length > 0 ? toolResults : undefined,
+ };
+ this.messages.push(assistantMessage);
+ yield { type: "done", message: assistantMessage };
+ } catch (err) {
+ const errorMsg = formatError(err, this.config);
+ yield { type: "error", error: errorMsg };
+ this.status = "error";
+ yield { type: "status", status: "error" };
+ return;
+ }
+
+ this.status = "idle";
+ yield { type: "status", status: "idle" };
+ }
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
new file mode 100644
index 0000000..b3907fa
--- /dev/null
+++ b/packages/core/src/index.ts
@@ -0,0 +1,9 @@
+// @dispatch/core — Agent runtime, LLM integration, tools
+
+export { Agent } from "./agent/agent.js";
+export { createProvider } from "./llm/provider.js";
+export { createListFilesTool } from "./tools/list-files.js";
+export { createReadFileTool } from "./tools/read-file.js";
+export { createToolRegistry } from "./tools/registry.js";
+export { createWriteFileTool } from "./tools/write-file.js";
+export * from "./types/index.js";
diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts
new file mode 100644
index 0000000..06f7b72
--- /dev/null
+++ b/packages/core/src/llm/provider.ts
@@ -0,0 +1,9 @@
+import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
+
+export function createProvider(config: { apiKey: string; baseURL: string }) {
+ return createOpenAICompatible({
+ name: "opencode-zen",
+ apiKey: config.apiKey,
+ baseURL: config.baseURL,
+ });
+}
diff --git a/packages/core/src/tools/list-files.ts b/packages/core/src/tools/list-files.ts
new file mode 100644
index 0000000..360ac98
--- /dev/null
+++ b/packages/core/src/tools/list-files.ts
@@ -0,0 +1,38 @@
+import { readdir } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createListFilesTool(workingDirectory: string): ToolDefinition {
+ return {
+ name: "list_files",
+ description: "List files and directories at a path relative to the working directory.",
+ parameters: z.object({
+ path: z
+ .string()
+ .optional()
+ .describe("Path to list, relative to the working directory. Defaults to '.'"),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const relPath = (args.path as string | undefined) ?? ".";
+ const absolutePath = resolve(join(workingDirectory, relPath));
+ const absoluteWorkDir = resolve(workingDirectory);
+
+ if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ return `Error: Path "${relPath}" is outside the working directory.`;
+ }
+
+ try {
+ const entries = await readdir(absolutePath, { withFileTypes: true });
+ if (entries.length === 0) {
+ return "(empty directory)";
+ }
+ return entries
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
+ .join("\n");
+ } catch (err) {
+ return `Error listing files: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ },
+ };
+}
diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts
new file mode 100644
index 0000000..476f243
--- /dev/null
+++ b/packages/core/src/tools/read-file.ts
@@ -0,0 +1,33 @@
+import { readFile } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createReadFileTool(workingDirectory: string): ToolDefinition {
+ return {
+ name: "read_file",
+ description: "Read the contents of a file relative to the working directory.",
+ parameters: z.object({
+ path: z.string().describe("Path to the file, relative to the working directory"),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const filePath = args.path as string;
+ const absolutePath = resolve(join(workingDirectory, filePath));
+ const absoluteWorkDir = resolve(workingDirectory);
+
+ if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ return `Error: Path "${filePath}" is outside the working directory.`;
+ }
+
+ try {
+ return await readFile(absolutePath, "utf8");
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") {
+ return `Error: File "${filePath}" not found.`;
+ }
+ return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ },
+ };
+}
diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts
new file mode 100644
index 0000000..4699c93
--- /dev/null
+++ b/packages/core/src/tools/registry.ts
@@ -0,0 +1,35 @@
+import { tool } from "ai";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createToolRegistry(tools: ToolDefinition[]) {
+ const toolMap = new Map<string, ToolDefinition>(tools.map((t) => [t.name, t]));
+
+ return {
+ getTools(): ToolDefinition[] {
+ return [...toolMap.values()];
+ },
+
+ getTool(name: string): ToolDefinition | undefined {
+ return toolMap.get(name);
+ },
+
+ getAISDKTools() {
+ const result: Record<string, ReturnType<typeof tool>> = {};
+ for (const [name, def] of toolMap) {
+ const schema = def.parameters;
+ const t = tool({
+ description: def.description,
+ parameters: schema instanceof z.ZodObject ? schema : z.object({}),
+ execute: async (args) => {
+ return def.execute(args as Record<string, unknown>);
+ },
+ });
+ // The AI SDK tool() overloads cause type narrowing issues when
+ // execute is provided. The runtime value is correct.
+ result[name] = t as unknown as ReturnType<typeof tool>;
+ }
+ return result;
+ },
+ };
+}
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
new file mode 100644
index 0000000..23bc72a
--- /dev/null
+++ b/packages/core/src/tools/write-file.ts
@@ -0,0 +1,33 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createWriteFileTool(workingDirectory: string): ToolDefinition {
+ return {
+ name: "write_file",
+ description: "Write content to a file relative to the working directory.",
+ parameters: z.object({
+ path: z.string().describe("Path to the file, relative to the working directory"),
+ content: z.string().describe("Content to write to the file"),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const filePath = args.path as string;
+ const content = args.content as string;
+ const absolutePath = resolve(join(workingDirectory, filePath));
+ const absoluteWorkDir = resolve(workingDirectory);
+
+ if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ return `Error: Path "${filePath}" is outside the working directory.`;
+ }
+
+ try {
+ await mkdir(dirname(absolutePath), { recursive: true });
+ await writeFile(absolutePath, content, "utf8");
+ return `Successfully wrote to "${filePath}".`;
+ } catch (err) {
+ return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ },
+ };
+}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
new file mode 100644
index 0000000..a408e7d
--- /dev/null
+++ b/packages/core/src/types/index.ts
@@ -0,0 +1,53 @@
+import type { ZodType } from "zod";
+
+// Message types for the agent conversation
+export type MessageRole = "user" | "assistant" | "tool";
+
+export interface ChatMessage {
+ role: MessageRole;
+ content: string;
+ toolCalls?: ToolCall[];
+ toolResults?: ToolResult[];
+}
+
+export interface ToolCall {
+ id: string;
+ name: string;
+ arguments: Record<string, unknown>;
+}
+
+export interface ToolResult {
+ toolCallId: string;
+ result: string;
+ isError: boolean;
+}
+
+// Agent status
+export type AgentStatus = "idle" | "running" | "error";
+
+// Agent events emitted during execution (for WebSocket streaming)
+export type AgentEvent =
+ | { type: "status"; status: AgentStatus }
+ | { type: "text-delta"; delta: string }
+ | { type: "tool-call"; toolCall: ToolCall }
+ | { type: "tool-result"; toolResult: ToolResult }
+ | { type: "error"; error: string }
+ | { type: "done"; message: ChatMessage };
+
+// Tool definition interface
+export interface ToolDefinition {
+ name: string;
+ description: string;
+ parameters: ZodType;
+ execute: (args: Record<string, unknown>) => Promise<string>;
+}
+
+// Agent configuration
+export interface AgentConfig {
+ model: string;
+ apiKey: string;
+ baseURL: string;
+ systemPrompt: string;
+ tools: ToolDefinition[];
+ workingDirectory: string;
+}
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
new file mode 100644
index 0000000..81d42c8
--- /dev/null
+++ b/packages/core/tests/agent/agent.test.ts
@@ -0,0 +1,244 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+import { Agent } from "../../src/agent/agent.js";
+import type { AgentConfig } from "../../src/types/index.js";
+
+// Mock the ai module's streamText
+vi.mock("ai", async (importOriginal) => {
+ const actual = await importOriginal<typeof import("ai")>();
+ return {
+ ...actual,
+ streamText: vi.fn(),
+ };
+});
+
+// Mock the provider
+vi.mock("@ai-sdk/openai-compatible", () => ({
+ createOpenAICompatible: vi.fn(() => (_model: string) => ({
+ type: "language-model",
+ modelId: _model,
+ })),
+}));
+
+function makeConfig(overrides: Partial<AgentConfig> = {}): AgentConfig {
+ return {
+ model: "test-model",
+ apiKey: "test-key",
+ baseURL: "https://example.com/v1",
+ systemPrompt: "You are a helpful assistant.",
+ tools: [],
+ workingDirectory: "/tmp",
+ ...overrides,
+ };
+}
+
+async function* makeFullStream(
+ events: Array<{ type: string; [key: string]: unknown }>,
+): AsyncGenerator<{ type: string; [key: string]: unknown }> {
+ for (const event of events) {
+ yield event;
+ }
+}
+
+interface MockStreamOptions {
+ events: Array<{ type: string; [key: string]: unknown }>;
+ steps?: Array<{ toolResults: Array<{ toolCallId: string; result: unknown }> }>;
+}
+
+function makeMockStreamResult(opts: MockStreamOptions) {
+ return {
+ fullStream: makeFullStream(opts.events),
+ steps: Promise.resolve(opts.steps ?? []),
+ } as ReturnType<typeof import("ai").streamText>;
+}
+
+describe("Agent", () => {
+ it("starts in idle status", () => {
+ const agent = new Agent(makeConfig());
+ expect(agent.status).toBe("idle");
+ });
+
+ it("has empty messages initially", () => {
+ const agent = new Agent(makeConfig());
+ expect(agent.messages).toHaveLength(0);
+ });
+
+ it("yields running then idle status events around a simple message", async () => {
+ const { streamText } = await import("ai");
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult({
+ events: [
+ { type: "text-delta", textDelta: "Hello!" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ],
+ }),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events = [];
+ for await (const event of agent.run("hi")) {
+ events.push(event);
+ }
+
+ const types = events.map((e) => e.type);
+ expect(types[0]).toBe("status");
+ expect(events[0]).toMatchObject({ type: "status", status: "running" });
+
+ const lastStatusEvent = events.filter((e) => e.type === "status").at(-1);
+ expect(lastStatusEvent).toMatchObject({ type: "status", status: "idle" });
+ });
+
+ it("yields text-delta events", async () => {
+ const { streamText } = await import("ai");
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult({
+ events: [
+ { type: "text-delta", textDelta: "Hello" },
+ { type: "text-delta", textDelta: " world" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ],
+ }),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events = [];
+ for await (const event of agent.run("test")) {
+ events.push(event);
+ }
+
+ const textDeltas = events.filter((e) => e.type === "text-delta");
+ expect(textDeltas).toHaveLength(2);
+ expect(textDeltas[0]).toMatchObject({ delta: "Hello" });
+ expect(textDeltas[1]).toMatchObject({ delta: " world" });
+ });
+
+ it("adds user message and assistant message to history", async () => {
+ const { streamText } = await import("ai");
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult({
+ events: [
+ { type: "text-delta", textDelta: "Response" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ],
+ }),
+ );
+
+ const agent = new Agent(makeConfig());
+ for await (const _ of agent.run("my question")) {
+ // consume generator
+ }
+
+ expect(agent.messages).toHaveLength(2);
+ expect(agent.messages[0]).toMatchObject({
+ role: "user",
+ content: "my question",
+ });
+ expect(agent.messages[1]).toMatchObject({
+ role: "assistant",
+ content: "Response",
+ });
+ });
+
+ it("yields done event with final message", async () => {
+ const { streamText } = await import("ai");
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult({
+ events: [
+ { type: "text-delta", textDelta: "Done!" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ],
+ }),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events = [];
+ for await (const event of agent.run("test")) {
+ events.push(event);
+ }
+
+ const doneEvent = events.find((e) => e.type === "done");
+ expect(doneEvent).toBeDefined();
+ expect(doneEvent).toMatchObject({
+ type: "done",
+ message: { role: "assistant", content: "Done!" },
+ });
+ });
+
+ it("yields tool-call and tool-result events", async () => {
+ const { streamText } = await import("ai");
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult({
+ events: [
+ {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ args: { path: "hello.txt" },
+ },
+ { type: "text-delta", textDelta: "Here is the file." },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ],
+ steps: [
+ {
+ toolResults: [{ toolCallId: "tc1", result: "file contents" }],
+ },
+ ],
+ }),
+ );
+
+ const toolDef = {
+ name: "read_file",
+ description: "reads a file",
+ parameters: z.object({ path: z.string() }),
+ execute: async (_args: Record<string, unknown>) => "file contents",
+ };
+
+ const agent = new Agent(makeConfig({ tools: [toolDef] }));
+ const events = [];
+ for await (const event of agent.run("read the file")) {
+ events.push(event);
+ }
+
+ const toolCallEvent = events.find((e) => e.type === "tool-call");
+ expect(toolCallEvent).toMatchObject({
+ type: "tool-call",
+ toolCall: { id: "tc1", name: "read_file" },
+ });
+
+ const toolResultEvent = events.find((e) => e.type === "tool-result");
+ expect(toolResultEvent).toMatchObject({
+ type: "tool-result",
+ toolResult: { toolCallId: "tc1", result: "file contents" },
+ });
+ });
+});
diff --git a/packages/core/tests/tools/list-files.test.ts b/packages/core/tests/tools/list-files.test.ts
new file mode 100644
index 0000000..ead1df3
--- /dev/null
+++ b/packages/core/tests/tools/list-files.test.ts
@@ -0,0 +1,41 @@
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createListFilesTool } from "../../src/tools/list-files.js";
+
+describe("list_files tool", () => {
+ let workDir: string;
+
+ beforeEach(async () => {
+ workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
+ });
+
+ afterEach(async () => {
+ await rm(workDir, { recursive: true, force: true });
+ });
+
+ it("lists directory contents", async () => {
+ const tool = createListFilesTool(workDir);
+ await writeFile(join(workDir, "file1.txt"), "a");
+ await writeFile(join(workDir, "file2.txt"), "b");
+ await mkdir(join(workDir, "subdir"));
+ const result = await tool.execute({ path: "." });
+ expect(result).toContain("file1.txt");
+ expect(result).toContain("file2.txt");
+ expect(result).toContain("subdir/");
+ });
+
+ it("defaults to current directory when path is undefined", async () => {
+ const tool = createListFilesTool(workDir);
+ await writeFile(join(workDir, "hello.txt"), "hi");
+ const result = await tool.execute({});
+ expect(result).toContain("hello.txt");
+ });
+
+ it("blocks path traversal", async () => {
+ const tool = createListFilesTool(workDir);
+ const result = await tool.execute({ path: "../" });
+ expect(result).toMatch(/outside the working directory/i);
+ });
+});
diff --git a/packages/core/tests/tools/read-file.test.ts b/packages/core/tests/tools/read-file.test.ts
new file mode 100644
index 0000000..ce65b37
--- /dev/null
+++ b/packages/core/tests/tools/read-file.test.ts
@@ -0,0 +1,36 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createReadFileTool } from "../../src/tools/read-file.js";
+
+describe("read_file tool", () => {
+ let workDir: string;
+
+ beforeEach(async () => {
+ workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
+ });
+
+ afterEach(async () => {
+ await rm(workDir, { recursive: true, force: true });
+ });
+
+ it("reads an existing file", async () => {
+ const tool = createReadFileTool(workDir);
+ await writeFile(join(workDir, "hello.txt"), "Hello, world!");
+ const result = await tool.execute({ path: "hello.txt" });
+ expect(result).toBe("Hello, world!");
+ });
+
+ it("returns error for non-existent file", async () => {
+ const tool = createReadFileTool(workDir);
+ const result = await tool.execute({ path: "missing.txt" });
+ expect(result).toMatch(/not found/i);
+ });
+
+ it("blocks path traversal", async () => {
+ const tool = createReadFileTool(workDir);
+ const result = await tool.execute({ path: "../etc/passwd" });
+ expect(result).toMatch(/outside the working directory/i);
+ });
+});
diff --git a/packages/core/tests/tools/registry.test.ts b/packages/core/tests/tools/registry.test.ts
new file mode 100644
index 0000000..b6f1fca
--- /dev/null
+++ b/packages/core/tests/tools/registry.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { z } from "zod";
+import { createToolRegistry } from "../../src/tools/registry.js";
+import type { ToolDefinition } from "../../src/types/index.js";
+
+const mockTool: ToolDefinition = {
+ name: "mock_tool",
+ description: "A mock tool for testing",
+ parameters: z.object({ input: z.string() }),
+ execute: async (_args) => "mock result",
+};
+
+const anotherTool: ToolDefinition = {
+ name: "another_tool",
+ description: "Another mock tool",
+ parameters: z.object({ value: z.number() }),
+ execute: async (_args) => "another result",
+};
+
+describe("createToolRegistry", () => {
+ it("returns all tools via getTools()", () => {
+ const registry = createToolRegistry([mockTool, anotherTool]);
+ const tools = registry.getTools();
+ expect(tools).toHaveLength(2);
+ expect(tools.map((t) => t.name)).toContain("mock_tool");
+ expect(tools.map((t) => t.name)).toContain("another_tool");
+ });
+
+ it("retrieves specific tool by name", () => {
+ const registry = createToolRegistry([mockTool, anotherTool]);
+ const tool = registry.getTool("mock_tool");
+ expect(tool).toBeDefined();
+ expect(tool?.name).toBe("mock_tool");
+ });
+
+ it("returns undefined for unknown tool", () => {
+ const registry = createToolRegistry([mockTool]);
+ expect(registry.getTool("nonexistent")).toBeUndefined();
+ });
+
+ it("getAISDKTools returns correct format", () => {
+ const registry = createToolRegistry([mockTool, anotherTool]);
+ const aiTools = registry.getAISDKTools();
+ expect(aiTools).toHaveProperty("mock_tool");
+ expect(aiTools).toHaveProperty("another_tool");
+ // Each should have description and parameters (AI SDK tool format)
+ expect(aiTools.mock_tool).toHaveProperty("description");
+ expect(aiTools.mock_tool).toHaveProperty("parameters");
+ });
+});
diff --git a/packages/core/tests/tools/write-file.test.ts b/packages/core/tests/tools/write-file.test.ts
new file mode 100644
index 0000000..01d7253
--- /dev/null
+++ b/packages/core/tests/tools/write-file.test.ts
@@ -0,0 +1,45 @@
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createWriteFileTool } from "../../src/tools/write-file.js";
+
+describe("write_file tool", () => {
+ let workDir: string;
+
+ beforeEach(async () => {
+ workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
+ });
+
+ afterEach(async () => {
+ await rm(workDir, { recursive: true, force: true });
+ });
+
+ it("writes a new file", async () => {
+ const tool = createWriteFileTool(workDir);
+ const result = await tool.execute({
+ path: "output.txt",
+ content: "test content",
+ });
+ expect(result).toMatch(/successfully wrote/i);
+ const written = await readFile(join(workDir, "output.txt"), "utf8");
+ expect(written).toBe("test content");
+ });
+
+ it("creates parent directories", async () => {
+ const tool = createWriteFileTool(workDir);
+ const result = await tool.execute({
+ path: "nested/dir/file.txt",
+ content: "nested",
+ });
+ expect(result).toMatch(/successfully wrote/i);
+ const written = await readFile(join(workDir, "nested/dir/file.txt"), "utf8");
+ expect(written).toBe("nested");
+ });
+
+ it("blocks path traversal", async () => {
+ const tool = createWriteFileTool(workDir);
+ const result = await tool.execute({ path: "../evil.txt", content: "bad" });
+ expect(result).toMatch(/outside the working directory/i);
+ });
+});
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
new file mode 100644
index 0000000..2d6fedd
--- /dev/null
+++ b/packages/core/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["@types/bun"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist", "tests"]
+}
diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts
new file mode 100644
index 0000000..ba60b3c
--- /dev/null
+++ b/packages/core/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["tests/**/*.test.ts"],
+ server: {
+ deps: {
+ // Force inline resolution for packages that break under Bun's
+ // .bun/ symlink layout in Docker environments
+ inline: ["zod"],
+ },
+ },
+ },
+});