summaryrefslogtreecommitdiffhomepage
path: root/packages/api/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/api/tests
downloaddispatch-main.tar.gz
dispatch-main.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/api/tests')
-rw-r--r--packages/api/tests/agent-manager.test.ts113
-rw-r--r--packages/api/tests/routes.test.ts106
2 files changed, 219 insertions, 0 deletions
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
new file mode 100644
index 0000000..17b0bff
--- /dev/null
+++ b/packages/api/tests/agent-manager.test.ts
@@ -0,0 +1,113 @@
+import type { AgentEvent } from "@dispatch/core";
+import { describe, expect, it, vi } from "vitest";
+
+// Mock @dispatch/core's Agent to avoid real LLM calls
+vi.mock("@dispatch/core", async () => {
+ const actual = await vi.importActual<typeof import("@dispatch/core")>("@dispatch/core");
+ return {
+ ...actual,
+ Agent: class MockAgent {
+ status = "idle";
+ messages: unknown[] = [];
+ async *run(_message: string) {
+ yield { type: "status", status: "running" } as const;
+ await new Promise<void>((r) => setTimeout(r, 10));
+ yield { type: "text-delta", delta: "Hello " } as const;
+ yield { type: "text-delta", delta: "world" } as const;
+ yield {
+ type: "done",
+ message: { role: "assistant", content: "Hello world" },
+ } as const;
+ yield { type: "status", status: "idle" } as const;
+ }
+ },
+ };
+});
+
+// Import after mock is defined (Vitest hoists vi.mock automatically)
+const { AgentManager } = await import("../src/agent-manager.js");
+
+describe("AgentManager", () => {
+ it("initial status is idle", () => {
+ const manager = new AgentManager();
+ expect(manager.getStatus()).toBe("idle");
+ });
+
+ it("initial messageCount is 0", () => {
+ const manager = new AgentManager();
+ expect(manager.getMessageCount()).toBe(0);
+ });
+
+ it("event listeners receive events during processMessage", async () => {
+ const manager = new AgentManager();
+ const events: AgentEvent[] = [];
+ manager.onEvent((event) => {
+ events.push(event);
+ });
+
+ await manager.processMessage("test");
+
+ expect(events.length).toBeGreaterThan(0);
+ expect(events[0]).toEqual({ type: "status", status: "running" });
+
+ const lastEvent = events[events.length - 1];
+ expect(lastEvent).toEqual({ type: "status", status: "idle" });
+
+ const doneEvent = events.find((e) => e.type === "done");
+ expect(doneEvent).toBeDefined();
+ });
+
+ it("emits text-delta events during processMessage", async () => {
+ const manager = new AgentManager();
+ const events: AgentEvent[] = [];
+ manager.onEvent((event) => {
+ events.push(event);
+ });
+
+ await manager.processMessage("hello");
+
+ const textDeltas = events.filter((e) => e.type === "text-delta");
+ expect(textDeltas.length).toBeGreaterThan(0);
+ });
+
+ it("messageCount increments after processMessage", async () => {
+ const manager = new AgentManager();
+ await manager.processMessage("hello");
+ expect(manager.getMessageCount()).toBe(1);
+ await manager.processMessage("world");
+ expect(manager.getMessageCount()).toBe(2);
+ });
+
+ it("status returns to idle after processMessage completes", async () => {
+ const manager = new AgentManager();
+ await manager.processMessage("test");
+ expect(manager.getStatus()).toBe("idle");
+ });
+
+ it("unsubscribe removes listener", async () => {
+ const manager = new AgentManager();
+ const events: AgentEvent[] = [];
+ const unsubscribe = manager.onEvent((event) => {
+ events.push(event);
+ });
+
+ unsubscribe();
+ await manager.processMessage("test");
+
+ expect(events.length).toBe(0);
+ });
+
+ it("multiple listeners all receive events", async () => {
+ const manager = new AgentManager();
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+
+ manager.onEvent(listener1);
+ manager.onEvent(listener2);
+
+ await manager.processMessage("test");
+
+ expect(listener1).toHaveBeenCalled();
+ expect(listener2).toHaveBeenCalled();
+ });
+});
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
new file mode 100644
index 0000000..d5384b3
--- /dev/null
+++ b/packages/api/tests/routes.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it, vi } from "vitest";
+
+// Mock @dispatch/core's Agent to avoid real LLM calls
+vi.mock("@dispatch/core", async () => {
+ const actual = await vi.importActual<typeof import("@dispatch/core")>("@dispatch/core");
+ return {
+ ...actual,
+ Agent: class MockAgent {
+ status = "idle";
+ messages: unknown[] = [];
+ async *run(_message: string) {
+ yield { type: "status", status: "running" } as const;
+ // Simulate some processing time so status stays "running"
+ await new Promise<void>((r) => setTimeout(r, 100));
+ yield { type: "text-delta", delta: "Hello " } as const;
+ yield { type: "text-delta", delta: "world" } as const;
+ yield {
+ type: "done",
+ message: { role: "assistant", content: "Hello world" },
+ } as const;
+ yield { type: "status", status: "idle" } as const;
+ }
+ },
+ };
+});
+
+const { app } = await import("../src/app.js");
+
+describe("GET /health", () => {
+ it("returns 200 with ok: true", async () => {
+ const res = await app.request("/health");
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body).toEqual({ ok: true });
+ });
+});
+
+describe("GET /status", () => {
+ it("returns idle status initially", async () => {
+ const res = await app.request("/status");
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.status).toBe("idle");
+ expect(typeof body.messageCount).toBe("number");
+ });
+});
+
+describe("POST /chat", () => {
+ it("returns 200 with valid message", async () => {
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hello world" }),
+ });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body).toEqual({ status: "ok" });
+ });
+
+ it("returns 400 with empty message", async () => {
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "" }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 with whitespace-only message", async () => {
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: " " }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 with missing message field", async () => {
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 409 when agent is already running", async () => {
+ // Start a message (non-blocking)
+ await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "first message" }),
+ });
+
+ // Small delay to let the async generator start and emit "running" status
+ await new Promise<void>((r) => setTimeout(r, 20));
+
+ // Immediately send a second — agent should be running
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "second message" }),
+ });
+ expect(res.status).toBe(409);
+ });
+});