summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-19 19:40:21 +0900
committerAdam Malczewski <[email protected]>2026-05-19 19:40:21 +0900
commitf78a91c20f658dd404277919a0b872b352c99bb6 (patch)
tree58cfffb655da4443f4b7a39543b86f988f15239f /packages/core/tests
downloaddispatch-f78a91c20f658dd404277919a0b872b352c99bb6.tar.gz
dispatch-f78a91c20f658dd404277919a0b872b352c99bb6.zip
Phase 1: single agent + basic UIHEADmain
- Bun monorepo with @dispatch/core, @dispatch/api, @dispatch/frontend - Agent runtime with Vercel AI SDK, streaming via WebSocket - Tools: read_file, write_file, list_files (scoped to working directory) - Hono API server with POST /chat, GET /status, GET /health, WS /ws - Svelte 5 + DaisyUI frontend with chat UI, theme switcher, copy button - OpenCode Go (Zen) as LLM provider, deepseek-v4-flash-free model - Docker setup (dev + prod) with bin/ scripts and gopass secrets - Biome v2 linting/formatting, Vitest tests (44 passing) - Debug info attached to error messages for diagnostics
Diffstat (limited to 'packages/core/tests')
-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
5 files changed, 416 insertions, 0 deletions
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);
+ });
+});