import { beforeEach, describe, expect, it } from "bun:test"; import type { Hono } from "hono"; import { EventBus } from "../src/agent/mailbox.ts"; import type { AgentConfig } from "../src/agent/parser.ts"; import type { AgentInfo, StreamEvent } from "../src/agent/types.ts"; import { createApp } from "../src/server.ts"; import type { ContainerAgent } from "../src/types.ts"; type Env = { Variables: { agent: ContainerAgent; agentId: string } }; function makeConfig(overrides?: Partial): AgentConfig { return { model: "test-model", identity: "Test agent identity", name: "test-agent", ifThen: [], schedule: [], skills: [], sanctums: [], sourcePath: "/tmp/test.soul", ...overrides, }; } function makeContainerAgent(overrides?: Partial): ContainerAgent { return { id: "test-agent", config: makeConfig(), process: {} as any, state: "running", startedAt: new Date(), restartCount: 0, messageCount: 5, pendingQueries: new Map(), buffer: "", events: new EventBus(), ...overrides, }; } function makeAgentInfo(overrides?: Partial): AgentInfo { return { id: "test-agent", state: "running", model: "test-model", identity: "Test agent identity", uptime: 1000, messageCount: 5, restartCount: 0, scheduleCount: 0, ...overrides, }; } function createMockSupervisor() { const agents = new Map(); const agent = makeContainerAgent(); agents.set("test-agent", agent); return { _agents: agents, _streamEvents: [] as StreamEvent[], listAgents(): AgentInfo[] { return Array.from(agents.values()).map((a) => makeAgentInfo({ id: a.id, state: a.state })); }, getAgent(id: string): ContainerAgent | undefined { return agents.get(id); }, getAgentInfo(id: string): AgentInfo | undefined { const a = agents.get(id); if (!a) return undefined; return makeAgentInfo({ id: a.id, state: a.state }); }, getAgentConfig(id: string): AgentConfig | undefined { return agents.get(id)?.config; }, hasAgent(id: string): boolean { return agents.has(id); }, async register(path: string): Promise { const id = path.replace(/^.*\//, "").replace(/\.soul$/, ""); const newAgent = makeContainerAgent({ id, config: makeConfig({ name: id }) }); agents.set(id, newAgent); return newAgent; }, async registerFromContent(name: string, _content: string): Promise { const newAgent = makeContainerAgent({ id: name, config: makeConfig({ name }) }); agents.set(name, newAgent); return newAgent; }, stop(id: string): boolean { const a = agents.get(id); if (!a) return false; a.state = "stopped"; return true; }, async restart(id: string): Promise { const a = agents.get(id); if (!a) throw new Error(`Agent '${id}' not found`); a.state = "running"; return a; }, async remove(id: string): Promise { return agents.delete(id); }, removeAll(): void { agents.clear(); }, async *streamChat(_id: string, _fromId: string, _content: string, _convId: string): AsyncGenerator { for (const event of this._streamEvents) { yield event; } }, async queryAgent(_id: string, action: string, _params?: Record): Promise { if (action === "get_history") return [{ role: "user", content: "hi" }]; if (action === "get_schedules") return []; if (action === "get_skills") return []; if (action === "get_message_count") return { count: 0 }; if (action === "delete_schedule") return { ok: true }; return {}; }, async seal(_id: string) { return { path: "/tmp/test.horcrux", size: 1024 }; }, async unseal(_path: string, name?: string): Promise { const id = name ?? "unsealed"; const a = makeContainerAgent({ id, config: makeConfig({ name: id }) }); agents.set(id, a); return a; }, onAgentError(_id: string, _err: Error): void {}, log(_agentId: string, _message: string): void {}, }; } describe("server routes", () => { let app: Hono; let sv: ReturnType; beforeEach(() => { sv = createMockSupervisor(); const ready = Promise.resolve(); app = createApp(sv as any, ready); }); // ---- GET /health ---- describe("GET /health", () => { it("returns status ok and agent count", async () => { const res = await app.request("/health"); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(body.status).toBe("ok"); expect(body.agents).toBe(1); }); it("reflects actual agent count", async () => { sv._agents.clear(); const res = await app.request("/health"); const body = (await res.json()) as any; expect(body.agents).toBe(0); }); }); // ---- GET /api/agents ---- describe("GET /api/agents", () => { it("returns agent list", async () => { const res = await app.request("/api/agents"); expect(res.status).toBe(200); const body = (await res.json()) as AgentInfo[]; expect(body).toHaveLength(1); expect(body[0]!.id).toBe("test-agent"); expect(body[0]!.state).toBe("running"); expect(body[0]!.model).toBe("test-model"); }); it("returns empty list when no agents", async () => { sv._agents.clear(); const res = await app.request("/api/agents"); const body = (await res.json()) as any; expect(body).toEqual([]); }); }); // ---- POST /api/agents ---- describe("POST /api/agents", () => { it("registers and returns 201 for new agent", async () => { const res = await app.request("/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ soulfilePath: "/tmp/new-agent.soul" }), }); expect(res.status).toBe(201); const body = (await res.json()) as any; expect(body.id).toBe("new-agent"); }); it("returns 200 for already-registered agent", async () => { const res = await app.request("/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ soulfilePath: "/tmp/test-agent.soul" }), }); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(body.id).toBe("test-agent"); }); it("returns 400 when soulfilePath is missing", async () => { const res = await app.request("/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(400); const body = (await res.json()) as any; expect(body.error).toContain("soulfilePath"); }); it("returns 400 when soulfilePath is not a string", async () => { const res = await app.request("/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ soulfilePath: 123 }), }); expect(res.status).toBe(400); }); }); // ---- DELETE /api/agents ---- describe("DELETE /api/agents", () => { it("removes all agents and returns ok", async () => { const res = await app.request("/api/agents", { method: "DELETE" }); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(body.ok).toBe(true); expect(sv._agents.size).toBe(0); }); }); // ---- DELETE /api/agents/:id ---- describe("DELETE /api/agents/:id", () => { it("returns ok for existing agent", async () => { const res = await app.request("/api/agents/test-agent", { method: "DELETE" }); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(body.ok).toBe(true); }); it("returns 404 for missing agent", async () => { const res = await app.request("/api/agents/nonexistent", { method: "DELETE" }); expect(res.status).toBe(404); const body = (await res.json()) as any; expect(body.error).toContain("not found"); }); }); // ---- POST /api/agents/:id/stop ---- describe("POST /api/agents/:id/stop", () => { it("returns agent info on success", async () => { const res = await app.request("/api/agents/test-agent/stop", { method: "POST" }); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(body.id).toBe("test-agent"); expect(body.state).toBe("stopped"); }); it("returns 404 for missing agent", async () => { const res = await app.request("/api/agents/nonexistent/stop", { method: "POST" }); expect(res.status).toBe(404); const body = (await res.json()) as any; expect(body.error).toContain("not found"); }); }); // ---- POST /api/agents/:id/start ---- describe("POST /api/agents/:id/start", () => { it("returns agent info on success", async () => { sv._agents.get("test-agent")!.state = "stopped"; const res = await app.request("/api/agents/test-agent/start", { method: "POST" }); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(body.id).toBe("test-agent"); expect(body.state).toBe("running"); }); it("returns 400 when restart throws", async () => { const origRestart = sv.restart; sv.restart = async () => { throw new Error("restart failed"); }; const res = await app.request("/api/agents/test-agent/start", { method: "POST" }); expect(res.status).toBe(400); const body = (await res.json()) as any; expect(body.error).toContain("restart failed"); sv.restart = origRestart; }); }); // ---- POST /api/agents/:id/messages ---- describe("POST /api/agents/:id/messages", () => { it("returns SSE stream with correct headers", async () => { sv._streamEvents = [{ type: "text", text: "Hello" }, { type: "done" }]; const res = await app.request("/api/agents/test-agent/messages", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "Hi there" }), }); expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toBe("text/event-stream"); expect(res.headers.get("Cache-Control")).toBe("no-cache"); const text = await res.text(); expect(text).toContain("data: "); expect(text).toContain('"type":"text"'); expect(text).toContain("[DONE]"); }); it("returns 400 when content is missing", async () => { const res = await app.request("/api/agents/test-agent/messages", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(400); const body = (await res.json()) as any; expect(body.error).toContain("content"); }); it("returns 400 when content is not a string", async () => { const res = await app.request("/api/agents/test-agent/messages", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: 42 }), }); expect(res.status).toBe(400); }); it("returns 503 for stopped agent", async () => { sv._agents.get("test-agent")!.state = "stopped"; const res = await app.request("/api/agents/test-agent/messages", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "hello" }), }); expect(res.status).toBe(503); const body = (await res.json()) as any; expect(body.error).toContain("not ready"); }); it("returns 404 for nonexistent agent", async () => { const res = await app.request("/api/agents/ghost/messages", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "hello" }), }); expect(res.status).toBe(404); const body = (await res.json()) as any; expect(body.error).toContain("not found"); }); }); // ---- GET /api/agents/:id/config ---- describe("GET /api/agents/:id/config", () => { it("returns config for existing agent", async () => { const res = await app.request("/api/agents/test-agent/config"); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(body.name).toBe("test-agent"); expect(body.model).toBe("test-model"); expect(body.identity).toBe("Test agent identity"); }); it("returns 404 for missing agent", async () => { const res = await app.request("/api/agents/nonexistent/config"); expect(res.status).toBe(404); const body = (await res.json()) as any; expect(body.error).toContain("not found"); }); }); // ---- CORS ---- describe("CORS", () => { it("OPTIONS returns cors headers", async () => { const res = await app.request("/api/agents", { method: "OPTIONS", headers: { Origin: "http://localhost:3000", "Access-Control-Request-Method": "GET", }, }); expect(res.headers.get("Access-Control-Allow-Origin")).toBeTruthy(); }); it("GET response includes CORS headers", async () => { const res = await app.request("/api/agents", { headers: { Origin: "http://localhost:3000" }, }); expect(res.headers.get("Access-Control-Allow-Origin")).toBeTruthy(); }); }); // ---- SPA fallback ---- describe("SPA fallback", () => { it("non-API GET falls through to UI handler", async () => { const res = await app.request("/some/random/path"); expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toContain("text/html"); }); it("root path serves HTML", async () => { const res = await app.request("/"); expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toContain("text/html"); }); }); // ---- POST /api/agents/:id/talk ---- describe("POST /api/agents/:id/talk", () => { it("returns JSON response aggregated from stream", async () => { sv._streamEvents = [{ type: "text", text: "Hello " }, { type: "text", text: "world" }, { type: "done" }]; const res = await app.request("/api/agents/test-agent/talk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fromId: "other-agent", message: "hi" }), }); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(body.response).toBe("Hello world"); }); it("returns 404 for missing agent", async () => { const res = await app.request("/api/agents/ghost/talk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fromId: "other", message: "hi" }), }); expect(res.status).toBe(404); }); it("returns 500 when stream yields error event", async () => { sv._streamEvents = [{ type: "error", error: "something broke" }]; const res = await app.request("/api/agents/test-agent/talk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fromId: "other", message: "hi" }), }); expect(res.status).toBe(500); const body = (await res.json()) as any; expect(body.error).toBe("something broke"); }); }); // ---- GET /api/agents/:id/history ---- describe("GET /api/agents/:id/history", () => { it("returns history for existing agent", async () => { const res = await app.request("/api/agents/test-agent/history"); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(Array.isArray(body)).toBe(true); }); it("returns 404 for missing agent", async () => { const res = await app.request("/api/agents/ghost/history"); expect(res.status).toBe(404); }); }); // ---- GET /api/agents/:id/schedules ---- describe("GET /api/agents/:id/schedules", () => { it("returns schedules for existing agent", async () => { const res = await app.request("/api/agents/test-agent/schedules"); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(Array.isArray(body)).toBe(true); }); it("returns 404 for missing agent", async () => { const res = await app.request("/api/agents/ghost/schedules"); expect(res.status).toBe(404); }); }); // ---- DELETE /api/agents/:id/schedules/:scheduleId ---- describe("DELETE /api/agents/:id/schedules/:scheduleId", () => { it("returns ok for valid schedule", async () => { const res = await app.request("/api/agents/test-agent/schedules/sched-1", { method: "DELETE" }); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(body.ok).toBe(true); }); it("returns 404 for missing agent", async () => { const res = await app.request("/api/agents/ghost/schedules/sched-1", { method: "DELETE" }); expect(res.status).toBe(404); }); }); // ---- GET /api/agents/:id/skills ---- describe("GET /api/agents/:id/skills", () => { it("returns skills for existing agent", async () => { const res = await app.request("/api/agents/test-agent/skills"); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(Array.isArray(body)).toBe(true); }); it("returns 404 for missing agent", async () => { const res = await app.request("/api/agents/ghost/skills"); expect(res.status).toBe(404); }); }); // ---- POST /api/agents/:id/intro ---- describe("POST /api/agents/:id/intro", () => { it("returns SSE stream for agent with no messages", async () => { sv._streamEvents = [{ type: "text", text: "Hi, I'm test-agent!" }, { type: "done" }]; const res = await app.request("/api/agents/test-agent/intro", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toBe("text/event-stream"); }); it("returns 404 for missing agent", async () => { const res = await app.request("/api/agents/ghost/intro", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(404); }); it("returns 503 for stopped agent", async () => { sv._agents.get("test-agent")!.state = "stopped"; const res = await app.request("/api/agents/test-agent/intro", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(503); }); it("returns 409 if agent already has messages", async () => { sv.queryAgent = async (_id: string, action: string) => { if (action === "get_message_count") return { count: 3 }; return {}; }; const res = await app.request("/api/agents/test-agent/intro", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(409); const body = (await res.json()) as any; expect(body.error).toContain("already has messages"); }); }); // ---- Error handling ---- describe("error handling", () => { it("returns 500 JSON on unhandled route error", async () => { sv.listAgents = () => { throw new Error("kaboom"); }; const res = await app.request("/api/agents"); expect(res.status).toBe(500); const body = (await res.json()) as any; expect(body.error).toContain("kaboom"); }); }); // ---- URL-encoded agent IDs ---- describe("URL-encoded agent IDs", () => { it("handles agents with special characters in ID", async () => { const specialId = "agent with spaces"; sv._agents.set(specialId, makeContainerAgent({ id: specialId, config: makeConfig({ name: specialId }) })); const res = await app.request(`/api/agents/${encodeURIComponent(specialId)}/config`); expect(res.status).toBe(200); const body = (await res.json()) as any; expect(body.name).toBe(specialId); }); }); });