summaryrefslogtreecommitdiffhomepage
path: root/packages/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/api')
-rw-r--r--packages/api/package.json20
-rw-r--r--packages/api/src/agent-manager.ts86
-rw-r--r--packages/api/src/app.ts46
-rw-r--r--packages/api/src/index.ts37
-rw-r--r--packages/api/src/types.ts2
-rw-r--r--packages/api/tests/agent-manager.test.ts113
-rw-r--r--packages/api/tests/routes.test.ts106
-rw-r--r--packages/api/tsconfig.json10
-rw-r--r--packages/api/vitest.config.ts12
9 files changed, 432 insertions, 0 deletions
diff --git a/packages/api/package.json b/packages/api/package.json
new file mode 100644
index 0000000..deea9f6
--- /dev/null
+++ b/packages/api/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@dispatch/api",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "scripts": {
+ "dev": "bun --watch src/index.ts",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "hono": "^4.0.0",
+ "@dispatch/core": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/bun": "latest"
+ }
+}
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
new file mode 100644
index 0000000..f60b8d3
--- /dev/null
+++ b/packages/api/src/agent-manager.ts
@@ -0,0 +1,86 @@
+import {
+ Agent,
+ type AgentEvent,
+ type AgentStatus,
+ createListFilesTool,
+ createReadFileTool,
+ createWriteFileTool,
+} from "@dispatch/core";
+
+const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have access to the following tools for working with files in the current working directory:
+
+- read_file: Read the contents of a file
+- write_file: Write content to a file (creates parent directories if needed)
+- list_files: List files and directories
+
+When asked to work with files, use these tools. Always confirm what you did after completing an action. Be concise and helpful.`;
+
+export class AgentManager {
+ private agent: Agent | null = null;
+ private status: AgentStatus = "idle";
+ private messageCount = 0;
+ private eventListeners: Set<(event: AgentEvent) => void> = new Set();
+
+ private getOrCreateAgent(): Agent {
+ if (!this.agent) {
+ const apiKey = process.env.OPENCODE_API_KEY ?? "";
+ const model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash-free";
+ const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
+
+ const tools = [
+ createReadFileTool(workingDirectory),
+ createWriteFileTool(workingDirectory),
+ createListFilesTool(workingDirectory),
+ ];
+
+ this.agent = new Agent({
+ model,
+ apiKey,
+ baseURL: "https://opencode.ai/zen/v1",
+ systemPrompt: SYSTEM_PROMPT,
+ tools,
+ workingDirectory,
+ });
+ }
+ return this.agent;
+ }
+
+ getStatus(): AgentStatus {
+ return this.status;
+ }
+
+ getMessageCount(): number {
+ return this.messageCount;
+ }
+
+ onEvent(listener: (event: AgentEvent) => void): () => void {
+ this.eventListeners.add(listener);
+ return () => {
+ this.eventListeners.delete(listener);
+ };
+ }
+
+ private emit(event: AgentEvent): void {
+ for (const listener of this.eventListeners) {
+ listener(event);
+ }
+ }
+
+ async processMessage(message: string): Promise<void> {
+ const agent = this.getOrCreateAgent();
+
+ this.messageCount += 1;
+
+ try {
+ for await (const event of agent.run(message)) {
+ this.status = event.type === "status" ? event.status : this.status;
+ this.emit(event);
+ }
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ this.status = "error";
+ this.emit({ type: "error", error: errorMsg });
+ this.emit({ type: "status", status: "error" });
+ }
+ }
+}
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
new file mode 100644
index 0000000..9c31eab
--- /dev/null
+++ b/packages/api/src/app.ts
@@ -0,0 +1,46 @@
+import { Hono } from "hono";
+import { cors } from "hono/cors";
+import { AgentManager } from "./agent-manager.js";
+
+export const agentManager = new AgentManager();
+
+export const app = new Hono();
+
+app.use(
+ "*",
+ cors({
+ origin: "http://localhost:5173",
+ credentials: true,
+ allowHeaders: ["Content-Type", "Authorization"],
+ allowMethods: ["GET", "POST", "OPTIONS"],
+ }),
+);
+
+app.get("/health", (c) => {
+ return c.json({ ok: true });
+});
+
+app.get("/status", (c) => {
+ return c.json({
+ status: agentManager.getStatus(),
+ messageCount: agentManager.getMessageCount(),
+ });
+});
+
+app.post("/chat", async (c) => {
+ const body = await c.req.json<{ message?: unknown }>();
+ const message = body.message;
+
+ if (typeof message !== "string" || message.trim() === "") {
+ return c.json({ error: "message must be a non-empty string" }, 400);
+ }
+
+ if (agentManager.getStatus() === "running") {
+ return c.json({ error: "agent is already running" }, 409);
+ }
+
+ // Non-blocking — let the agent run in the background
+ agentManager.processMessage(message).catch(console.error);
+
+ return c.json({ status: "ok" });
+});
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
new file mode 100644
index 0000000..02b04b6
--- /dev/null
+++ b/packages/api/src/index.ts
@@ -0,0 +1,37 @@
+import { createBunWebSocket } from "hono/bun";
+import { agentManager, app } from "./app.js";
+
+const { upgradeWebSocket, websocket } = createBunWebSocket();
+
+app.get(
+ "/ws",
+ upgradeWebSocket((_c) => {
+ return {
+ onOpen(_event, ws) {
+ // Send current status immediately
+ ws.send(JSON.stringify({ type: "status", status: agentManager.getStatus() }));
+
+ const unsubscribe = agentManager.onEvent((event) => {
+ ws.send(JSON.stringify(event));
+ });
+
+ // Store unsubscribe fn on the raw socket for cleanup
+ (ws as unknown as { _unsub?: () => void })._unsub = unsubscribe;
+ },
+ onClose(_event, ws) {
+ const unsub = (ws as unknown as { _unsub?: () => void })._unsub;
+ if (unsub) {
+ unsub();
+ }
+ },
+ };
+ }),
+);
+
+export { app };
+
+export default {
+ port: 3000,
+ fetch: app.fetch,
+ websocket,
+};
diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts
new file mode 100644
index 0000000..a88e41b
--- /dev/null
+++ b/packages/api/src/types.ts
@@ -0,0 +1,2 @@
+// Re-export types from @dispatch/core for convenience
+export type { AgentEvent, AgentStatus } from "@dispatch/core";
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);
+ });
+});
diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json
new file mode 100644
index 0000000..2d6fedd
--- /dev/null
+++ b/packages/api/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/api/vitest.config.ts b/packages/api/vitest.config.ts
new file mode 100644
index 0000000..854422e
--- /dev/null
+++ b/packages/api/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["tests/**/*.test.ts"],
+ server: {
+ deps: {
+ inline: ["zod", "@dispatch/core"],
+ },
+ },
+ },
+});