diff options
Diffstat (limited to 'packages/api')
| -rw-r--r-- | packages/api/package.json | 20 | ||||
| -rw-r--r-- | packages/api/src/agent-manager.ts | 86 | ||||
| -rw-r--r-- | packages/api/src/app.ts | 46 | ||||
| -rw-r--r-- | packages/api/src/index.ts | 37 | ||||
| -rw-r--r-- | packages/api/src/types.ts | 2 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 113 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 106 | ||||
| -rw-r--r-- | packages/api/tsconfig.json | 10 | ||||
| -rw-r--r-- | packages/api/vitest.config.ts | 12 |
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"], + }, + }, + }, +}); |
