import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentConfigInfo, AgentEvent, AgentInfo } from "./agents"; import { agentsApi, subscribeAgentStream } from "./agents"; const mockFetch = vi.fn(); beforeEach(() => { vi.stubGlobal("fetch", mockFetch); }); afterEach(() => { vi.restoreAllMocks(); }); function okResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" }, }); } function errorResponse(status: number, text: string) { return new Response(text, { status }); } const sampleAgent: AgentInfo = { story_id: "42_story_test", agent_name: "coder", status: "running", session_id: null, worktree_path: null, base_branch: null, log_session_id: null, }; const sampleConfig: AgentConfigInfo = { name: "coder", role: "engineer", model: "claude-sonnet-4-6", allowed_tools: null, max_turns: null, max_budget_usd: null, }; // ── agentsApi ──────────────────────────────────────────────────────────────── describe("agentsApi", () => { describe("startAgent", () => { it("sends POST to /agents/start with story_id", async () => { mockFetch.mockResolvedValueOnce(okResponse(sampleAgent)); const result = await agentsApi.startAgent("42_story_test"); expect(mockFetch).toHaveBeenCalledWith( "/api/agents/start", expect.objectContaining({ method: "POST", body: JSON.stringify({ story_id: "42_story_test", agent_name: undefined, }), }), ); expect(result).toEqual(sampleAgent); }); it("sends POST with optional agent_name", async () => { mockFetch.mockResolvedValueOnce(okResponse(sampleAgent)); await agentsApi.startAgent("42_story_test", "coder"); expect(mockFetch).toHaveBeenCalledWith( "/api/agents/start", expect.objectContaining({ body: JSON.stringify({ story_id: "42_story_test", agent_name: "coder", }), }), ); }); it("uses custom baseUrl when provided", async () => { mockFetch.mockResolvedValueOnce(okResponse(sampleAgent)); await agentsApi.startAgent( "42_story_test", undefined, "http://localhost:3002/api", ); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3002/api/agents/start", expect.objectContaining({ method: "POST" }), ); }); }); describe("stopAgent", () => { it("sends POST to /agents/stop with story_id and agent_name", async () => { mockFetch.mockResolvedValueOnce(okResponse(true)); const result = await agentsApi.stopAgent("42_story_test", "coder"); expect(mockFetch).toHaveBeenCalledWith( "/api/agents/stop", expect.objectContaining({ method: "POST", body: JSON.stringify({ story_id: "42_story_test", agent_name: "coder", }), }), ); expect(result).toBe(true); }); it("uses custom baseUrl when provided", async () => { mockFetch.mockResolvedValueOnce(okResponse(false)); await agentsApi.stopAgent( "42_story_test", "coder", "http://localhost:3002/api", ); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3002/api/agents/stop", expect.objectContaining({ method: "POST" }), ); }); }); describe("listAgents", () => { it("sends GET to /agents and returns agent list", async () => { mockFetch.mockResolvedValueOnce(okResponse([sampleAgent])); const result = await agentsApi.listAgents(); expect(mockFetch).toHaveBeenCalledWith( "/api/agents", expect.objectContaining({}), ); expect(result).toEqual([sampleAgent]); }); it("returns empty array when no agents running", async () => { mockFetch.mockResolvedValueOnce(okResponse([])); const result = await agentsApi.listAgents(); expect(result).toEqual([]); }); it("uses custom baseUrl when provided", async () => { mockFetch.mockResolvedValueOnce(okResponse([])); await agentsApi.listAgents("http://localhost:3002/api"); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3002/api/agents", expect.objectContaining({}), ); }); }); describe("getAgentConfig", () => { it("sends GET to /agents/config and returns config list", async () => { mockFetch.mockResolvedValueOnce(okResponse([sampleConfig])); const result = await agentsApi.getAgentConfig(); expect(mockFetch).toHaveBeenCalledWith( "/api/agents/config", expect.objectContaining({}), ); expect(result).toEqual([sampleConfig]); }); it("uses custom baseUrl when provided", async () => { mockFetch.mockResolvedValueOnce(okResponse([sampleConfig])); await agentsApi.getAgentConfig("http://localhost:3002/api"); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3002/api/agents/config", expect.objectContaining({}), ); }); }); describe("reloadConfig", () => { it("sends POST to /agents/config/reload", async () => { mockFetch.mockResolvedValueOnce(okResponse([sampleConfig])); const result = await agentsApi.reloadConfig(); expect(mockFetch).toHaveBeenCalledWith( "/api/agents/config/reload", expect.objectContaining({ method: "POST" }), ); expect(result).toEqual([sampleConfig]); }); it("uses custom baseUrl when provided", async () => { mockFetch.mockResolvedValueOnce(okResponse([])); await agentsApi.reloadConfig("http://localhost:3002/api"); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3002/api/agents/config/reload", expect.objectContaining({ method: "POST" }), ); }); }); describe("error handling", () => { it("throws on non-ok response with body text", async () => { mockFetch.mockResolvedValueOnce(errorResponse(404, "agent not found")); await expect(agentsApi.listAgents()).rejects.toThrow("agent not found"); }); it("throws with status code when no body", async () => { mockFetch.mockResolvedValueOnce(errorResponse(500, "")); await expect(agentsApi.listAgents()).rejects.toThrow( "Request failed (500)", ); }); }); }); // ── subscribeAgentStream ───────────────────────────────────────────────────── interface MockESInstance { url: string; onmessage: ((e: { data: string }) => void) | null; onerror: ((e: Event) => void) | null; close: ReturnType; simulateMessage: (data: unknown) => void; simulateError: (e: Event) => void; } function makeMockEventSource() { const instances: MockESInstance[] = []; class MockEventSource { onmessage: ((e: { data: string }) => void) | null = null; onerror: ((e: Event) => void) | null = null; close = vi.fn(); constructor(public url: string) { instances.push(this as unknown as MockESInstance); } simulateMessage(data: unknown) { this.onmessage?.({ data: JSON.stringify(data) }); } simulateError(e: Event) { this.onerror?.(e); } } return { MockEventSource, instances }; } describe("subscribeAgentStream", () => { let instances: MockESInstance[]; beforeEach(() => { const { MockEventSource, instances: inst } = makeMockEventSource(); instances = inst; vi.stubGlobal("EventSource", MockEventSource); }); it("creates an EventSource with encoded story and agent in URL", () => { subscribeAgentStream("42_story_test", "coder", vi.fn()); expect(instances).toHaveLength(1); expect(instances[0].url).toContain( `/agents/${encodeURIComponent("42_story_test")}/${encodeURIComponent("coder")}/stream`, ); }); it("calls onEvent when a message is received", () => { const onEvent = vi.fn(); subscribeAgentStream("42_story_test", "coder", onEvent); const event: AgentEvent = { type: "output", text: "hello" }; instances[0].simulateMessage(event); expect(onEvent).toHaveBeenCalledWith(event); }); it("closes EventSource on 'done' type event", () => { subscribeAgentStream("42_story_test", "coder", vi.fn()); instances[0].simulateMessage({ type: "done" }); expect(instances[0].close).toHaveBeenCalled(); }); it("closes EventSource on 'error' type event", () => { subscribeAgentStream("42_story_test", "coder", vi.fn()); instances[0].simulateMessage({ type: "error", message: "something failed", }); expect(instances[0].close).toHaveBeenCalled(); }); it("closes EventSource on status=stopped event", () => { subscribeAgentStream("42_story_test", "coder", vi.fn()); instances[0].simulateMessage({ type: "status", status: "stopped" }); expect(instances[0].close).toHaveBeenCalled(); }); it("does not close on status=running event", () => { subscribeAgentStream("42_story_test", "coder", vi.fn()); instances[0].simulateMessage({ type: "status", status: "running" }); expect(instances[0].close).not.toHaveBeenCalled(); }); it("does not close on 'output' event", () => { subscribeAgentStream("42_story_test", "coder", vi.fn()); instances[0].simulateMessage({ type: "output", text: "building..." }); expect(instances[0].close).not.toHaveBeenCalled(); }); it("calls onError and closes on EventSource onerror", () => { const onError = vi.fn(); subscribeAgentStream("42_story_test", "coder", vi.fn(), onError); const err = new Event("error"); instances[0].simulateError(err); expect(onError).toHaveBeenCalledWith(err); expect(instances[0].close).toHaveBeenCalled(); }); it("closes EventSource when onError is not provided", () => { subscribeAgentStream("42_story_test", "coder", vi.fn()); const err = new Event("error"); instances[0].simulateError(err); expect(instances[0].close).toHaveBeenCalled(); }); it("closes EventSource when cleanup function is called", () => { const cleanup = subscribeAgentStream("42_story_test", "coder", vi.fn()); cleanup(); expect(instances[0].close).toHaveBeenCalled(); }); it("handles malformed JSON without throwing", () => { subscribeAgentStream("42_story_test", "coder", vi.fn()); expect(() => { instances[0].onmessage?.({ data: "{ not valid json" }); }).not.toThrow(); }); it("delivers multiple events before a terminal event", () => { const onEvent = vi.fn(); subscribeAgentStream("42_story_test", "coder", onEvent); instances[0].simulateMessage({ type: "output", text: "line 1" }); instances[0].simulateMessage({ type: "output", text: "line 2" }); instances[0].simulateMessage({ type: "done" }); expect(onEvent).toHaveBeenCalledTimes(3); expect(instances[0].close).toHaveBeenCalledTimes(1); }); });