/** * Container-based integration tests. * * These run real agents in Podman containers with real OpenRouter calls. * Gated behind podman availability + OPENROUTER_API_KEY. * Uses a free model to avoid costs. * * Merged into a single file to prevent env var races when bun * runs test files concurrently in the same process. */ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const testDir = join(tmpdir(), `mozart-integ-${process.pid}-${Date.now()}`); process.env.MOZART_DIR = testDir; async function imageExists(name: string): Promise { const proc = Bun.spawn(["podman", "image", "exists", name], { stdout: "ignore", stderr: "ignore" }); return (await proc.exited) === 0; } const podmanOk = await Bun.spawn(["podman", "info"], { stdout: "ignore", stderr: "ignore" }) .exited.then((c) => c === 0) .catch(() => false); const hasKey = !!process.env.OPENROUTER_API_KEY; const hasProd = podmanOk && (await imageExists("mozart-agent:latest")); const hasDev = podmanOk && (await imageExists("mozart-agent-dev:latest")); if (!hasProd && hasDev) process.env.MOZART_DEV = "1"; const podmanReady = hasKey && (hasProd || hasDev); const { killContainer } = await import("../../src/podman.ts"); const { createApp } = await import("../../src/server.ts"); const { Supervisor } = await import("../../src/supervisor.ts"); function parseSSEEvents(body: string): Array> { return body .split("\n") .filter((l) => l.startsWith("data: ") && l !== "data: [DONE]") .map((l) => { try { return JSON.parse(l.slice(6)); } catch { return null; } }) .filter(Boolean) as Array>; } // --------------------------------------------------------------------------- // Suite 1: Full agent lifecycle (register → start → message → stop → restart → remove) // --------------------------------------------------------------------------- const LIFECYCLE_ID = "integ-test"; const LIFECYCLE_SOUL = [ "MODEL google/gemma-3-4b-it:free", "SOUL A minimal test agent for integration testing.", "", ].join("\n"); describe.skipIf(!podmanReady)("integration: full agent lifecycle", () => { let app: ReturnType; let supervisor: InstanceType; let soulPath: string; beforeAll(() => { mkdirSync(testDir, { recursive: true }); soulPath = join(testDir, `${LIFECYCLE_ID}.soul`); writeFileSync(soulPath, LIFECYCLE_SOUL); supervisor = new Supervisor(process.env.OPENROUTER_API_KEY ?? ""); app = createApp(supervisor, Promise.resolve()); }); afterAll(() => { try { supervisor?.shutdownAll(); } catch {} try { killContainer(LIFECYCLE_ID); } catch {} }); async function waitForAgentState(target: string, timeoutMs = 60_000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (supervisor.getAgent(LIFECYCLE_ID)?.state === target) return; await Bun.sleep(250); } const current = supervisor.getAgent(LIFECYCLE_ID)?.state ?? "not found"; throw new Error(`Agent did not reach '${target}' within ${timeoutMs}ms (current: ${current})`); } async function waitForContainerExit(timeoutMs = 15_000): Promise { const containerName = `mozart-${LIFECYCLE_ID}`; const start = Date.now(); while (Date.now() - start < timeoutMs) { const proc = Bun.spawn(["podman", "inspect", "--format", "{{.State.Status}}", containerName], { stdout: "pipe", stderr: "pipe", }); const code = await proc.exited; if (code !== 0) return; const status = (await new Response(proc.stdout).text()).trim(); if (status === "exited" || status === "stopped" || status === "created") return; await Bun.sleep(500); } throw new Error(`Container did not exit within ${timeoutMs}ms`); } it("registers a test agent via POST /api/agents", async () => { const res = await app.request("/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ soulfilePath: soulPath }), }); expect(res.status).toBe(201); const body = (await res.json()) as { id: string; model: string }; expect(body.id).toBe(LIFECYCLE_ID); expect(body.model).toBe("google/gemma-3-4b-it:free"); }, 90_000); it("reaches running state after container starts", async () => { await waitForAgentState("running"); const info = supervisor.getAgentInfo(LIFECYCLE_ID); expect(info).toBeDefined(); expect(info!.state).toBe("running"); expect(info!.id).toBe(LIFECYCLE_ID); }, 90_000); it("appears in agent list", async () => { const res = await app.request("/api/agents"); expect(res.status).toBe(200); const agents = (await res.json()) as { id: string; state: string }[]; const found = agents.find((a) => a.id === LIFECYCLE_ID); expect(found).toBeDefined(); expect(found!.state).toBe("running"); }); it("streams SSE response for a message", async () => { const res = await app.request(`/api/agents/${LIFECYCLE_ID}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "Say hello in one sentence." }), }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("text/event-stream"); const body = await res.text(); const dataLines = body.split("\n").filter((l) => l.startsWith("data: ")); expect(dataLines.length).toBeGreaterThan(0); const hasDone = dataLines.some((l) => l === "data: [DONE]"); const hasError = dataLines.some((l) => l.includes('"type":"error"')); const hasText = dataLines.some((l) => l.includes('"type":"text"')); expect(hasDone || hasError || hasText).toBe(true); }, 60_000); it("stops the agent", async () => { const res = await app.request(`/api/agents/${LIFECYCLE_ID}/stop`, { method: "POST" }); expect(res.status).toBe(200); const body = (await res.json()) as { state: string }; expect(body.state).toBe("stopped"); await waitForContainerExit(); }, 90_000); it("restarts the stopped agent", async () => { const res = await app.request(`/api/agents/${LIFECYCLE_ID}/start`, { method: "POST" }); expect(res.status).toBe(200); const body = (await res.json()) as { id: string }; expect(body.id).toBe(LIFECYCLE_ID); await waitForAgentState("running"); expect(supervisor.getAgent(LIFECYCLE_ID)?.state).toBe("running"); }, 90_000); it("removes the agent", async () => { const res = await app.request(`/api/agents/${LIFECYCLE_ID}`, { method: "DELETE" }); expect(res.status).toBe(200); expect(((await res.json()) as { ok: boolean }).ok).toBe(true); }, 15_000); it("confirms agent is gone from list and supervisor", async () => { const res = await app.request("/api/agents"); expect(res.status).toBe(200); const agents = (await res.json()) as { id: string }[]; expect(agents.find((a) => a.id === LIFECYCLE_ID)).toBeUndefined(); expect(supervisor.hasAgent(LIFECYCLE_ID)).toBe(false); }); }); // --------------------------------------------------------------------------- // Suite 2: Real LLM smoke tests (message round-trips with SSE) // --------------------------------------------------------------------------- const SMOKE_ID = "wf-e2e-test"; const SMOKE_SOUL = [ "MODEL google/gemma-3-4b-it:free", "SOUL You are a helpful test agent. When asked to save something to memory, use the save_to_memory tool. When asked to recall, use search_memory.", "", ].join("\n"); describe.skipIf(!podmanReady)("integration: real LLM smoke", () => { let app: ReturnType; let supervisor: InstanceType; let soulPath: string; beforeAll(async () => { mkdirSync(testDir, { recursive: true }); soulPath = join(testDir, `${SMOKE_ID}.soul`); writeFileSync(soulPath, SMOKE_SOUL); supervisor = new Supervisor(process.env.OPENROUTER_API_KEY ?? ""); app = createApp(supervisor, Promise.resolve()); const res = await app.request("/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ soulfilePath: soulPath }), }); expect(res.status).toBe(201); const start = Date.now(); while (Date.now() - start < 60_000) { if (supervisor.getAgent(SMOKE_ID)?.state === "running") break; await Bun.sleep(250); } expect(supervisor.getAgent(SMOKE_ID)?.state).toBe("running"); }, 90_000); afterAll(() => { try { supervisor?.shutdownAll(); } catch {} try { killContainer(SMOKE_ID); } catch {} }); it("agent responds to a message with SSE events", async () => { const res = await app.request(`/api/agents/${SMOKE_ID}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "Say hello in one short sentence." }), }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("text/event-stream"); const body = await res.text(); const dataLines = body.split("\n").filter((l) => l.startsWith("data: ")); expect(dataLines.length).toBeGreaterThan(0); const events = parseSSEEvents(body); const types = new Set(events.map((e) => e.type)); const meaningful = ["text", "tool_use", "thinking", "done", "error"]; const hasMeaningful = meaningful.some((t) => types.has(t)); expect(hasMeaningful).toBe(true); }, 60_000); it("second message gets a valid SSE response too", async () => { const res = await app.request(`/api/agents/${SMOKE_ID}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "What is 2 + 2?" }), }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("text/event-stream"); const body = await res.text(); const dataLines = body.split("\n").filter((l) => l.startsWith("data: ")); expect(dataLines.length).toBeGreaterThan(0); const hasDone = dataLines.some((l) => l === "data: [DONE]"); const events = parseSSEEvents(body); const hasContent = events.some((e) => e.type === "text" || e.type === "done" || e.type === "error"); expect(hasDone || hasContent).toBe(true); }, 60_000); });