/** * @file useAgentStream.test.ts * @description Unit tests for the useAgentStream hook * * Tests cover: * - SSE event parsing (agent:spawned, agent:output, agent:completed, agent:error) * - Agent lifecycle state transitions * - Auto-reconnect behavior on connection loss * - Cleanup on unmount * - Dismiss agent */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useAgentStream } from "../useAgentStream"; // ========================================== // Mock EventSource // ========================================== interface MockEventSourceInstance { url: string; onopen: (() => void) | null; onerror: ((event: Event) => void) | null; onmessage: ((event: MessageEvent) => void) | null; close: ReturnType; addEventListener: ReturnType; dispatchEvent: (type: string, data: string) => void; _listeners: Map) => void)[]>; readyState: number; } let mockEventSourceInstances: MockEventSourceInstance[] = []; const MockEventSource = vi.fn(function (this: MockEventSourceInstance, url: string): void { this.url = url; this.onopen = null; this.onerror = null; this.onmessage = null; this.close = vi.fn(); this.readyState = 0; this._listeners = new Map(); this.addEventListener = vi.fn( (type: string, handler: (event: MessageEvent) => void): void => { if (!this._listeners.has(type)) { this._listeners.set(type, []); } const list = this._listeners.get(type); if (list) list.push(handler); } ); this.dispatchEvent = (type: string, data: string): void => { const handlers = this._listeners.get(type) ?? []; const event = new MessageEvent(type, { data }); for (const handler of handlers) { handler(event); } }; mockEventSourceInstances.push(this); }); // Add static constants Object.assign(MockEventSource, { CONNECTING: 0, OPEN: 1, CLOSED: 2, }); vi.stubGlobal("EventSource", MockEventSource); // ========================================== // Helpers // ========================================== function getLatestES(): MockEventSourceInstance { const es = mockEventSourceInstances[mockEventSourceInstances.length - 1]; if (!es) throw new Error("No EventSource instance created"); return es; } function triggerOpen(): void { const es = getLatestES(); if (es.onopen) es.onopen(); } function triggerError(): void { const es = getLatestES(); if (es.onerror) es.onerror(new Event("error")); } function emitEvent(type: string, data: unknown): void { const es = getLatestES(); es.dispatchEvent(type, JSON.stringify(data)); } // ========================================== // Tests // ========================================== describe("useAgentStream", () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); mockEventSourceInstances = []; }); afterEach(() => { vi.runAllTimers(); vi.useRealTimers(); }); // ========================================== // Initialization // ========================================== describe("initialization", () => { it("creates an EventSource connecting to /api/orchestrator/events", () => { renderHook(() => useAgentStream()); expect(MockEventSource).toHaveBeenCalledWith("/api/orchestrator/events"); }); it("starts with isConnected=false before onopen fires", () => { const { result } = renderHook(() => useAgentStream()); expect(result.current.isConnected).toBe(false); }); it("starts with an empty agents map", () => { const { result } = renderHook(() => useAgentStream()); expect(result.current.agents.size).toBe(0); }); it("sets isConnected=true when EventSource opens", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); }); expect(result.current.isConnected).toBe(true); }); it("clears connectionError when EventSource opens", () => { const { result } = renderHook(() => useAgentStream()); // Trigger an error first to set connectionError act(() => { triggerError(); }); // Start a fresh reconnect and open it act(() => { vi.advanceTimersByTime(2000); }); act(() => { triggerOpen(); }); expect(result.current.connectionError).toBeNull(); }); }); // ========================================== // SSE event: agent:spawned // ========================================== describe("agent:spawned event", () => { it("adds an agent with status=spawning", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker", jobId: "job-abc" }); }); expect(result.current.agents.has("agent-1")).toBe(true); expect(result.current.agents.get("agent-1")?.status).toBe("spawning"); }); it("sets agentType from the type field", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "planner" }); }); expect(result.current.agents.get("agent-1")?.agentType).toBe("planner"); }); it("defaults agentType to 'agent' when type is missing", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-2" }); }); expect(result.current.agents.get("agent-2")?.agentType).toBe("agent"); }); it("stores jobId when present", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-3", type: "worker", jobId: "job-xyz" }); }); expect(result.current.agents.get("agent-3")?.jobId).toBe("job-xyz"); }); it("starts with empty outputLines", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); }); expect(result.current.agents.get("agent-1")?.outputLines).toEqual([]); }); }); // ========================================== // SSE event: agent:output // ========================================== describe("agent:output event", () => { it("appends output to the agent's outputLines", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); emitEvent("agent:output", { agentId: "agent-1", data: "Hello world\n" }); }); expect(result.current.agents.get("agent-1")?.outputLines).toContain("Hello world\n"); }); it("transitions status from spawning to running on first output", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); emitEvent("agent:output", { agentId: "agent-1", data: "Starting...\n" }); }); expect(result.current.agents.get("agent-1")?.status).toBe("running"); }); it("accumulates multiple output lines", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); emitEvent("agent:output", { agentId: "agent-1", data: "Line 1\n" }); emitEvent("agent:output", { agentId: "agent-1", data: "Line 2\n" }); emitEvent("agent:output", { agentId: "agent-1", data: "Line 3\n" }); }); const lines = result.current.agents.get("agent-1")?.outputLines ?? []; expect(lines).toHaveLength(3); expect(lines[0]).toBe("Line 1\n"); expect(lines[1]).toBe("Line 2\n"); expect(lines[2]).toBe("Line 3\n"); }); it("creates a new agent entry if output arrives before spawned event", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:output", { agentId: "unknown-agent", data: "Surprise output\n" }); }); expect(result.current.agents.has("unknown-agent")).toBe(true); expect(result.current.agents.get("unknown-agent")?.status).toBe("running"); }); }); // ========================================== // SSE event: agent:completed // ========================================== describe("agent:completed event", () => { it("sets status to completed", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); emitEvent("agent:output", { agentId: "agent-1", data: "Working...\n" }); emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 }); }); expect(result.current.agents.get("agent-1")?.status).toBe("completed"); }); it("stores the exitCode", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); emitEvent("agent:completed", { agentId: "agent-1", exitCode: 42 }); }); expect(result.current.agents.get("agent-1")?.exitCode).toBe(42); }); it("sets endedAt timestamp", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 }); }); expect(result.current.agents.get("agent-1")?.endedAt).toBeDefined(); }); it("ignores completed event for unknown agent", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:completed", { agentId: "ghost-agent", exitCode: 0 }); }); expect(result.current.agents.has("ghost-agent")).toBe(false); }); }); // ========================================== // SSE event: agent:error // ========================================== describe("agent:error event", () => { it("sets status to error", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); emitEvent("agent:error", { agentId: "agent-1", error: "Out of memory" }); }); expect(result.current.agents.get("agent-1")?.status).toBe("error"); }); it("stores the error message", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); emitEvent("agent:error", { agentId: "agent-1", error: "Segfault" }); }); expect(result.current.agents.get("agent-1")?.errorMessage).toBe("Segfault"); }); it("sets endedAt on error", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); emitEvent("agent:error", { agentId: "agent-1", error: "Crash" }); }); expect(result.current.agents.get("agent-1")?.endedAt).toBeDefined(); }); it("ignores error event for unknown agent", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:error", { agentId: "ghost-agent", error: "Crash" }); }); expect(result.current.agents.has("ghost-agent")).toBe(false); }); }); // ========================================== // Reconnect behavior // ========================================== describe("auto-reconnect", () => { it("sets isConnected=false on error", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); }); act(() => { triggerError(); }); expect(result.current.isConnected).toBe(false); }); it("sets connectionError on error", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); triggerError(); }); expect(result.current.connectionError).not.toBeNull(); }); it("creates a new EventSource after reconnect delay", () => { renderHook(() => useAgentStream()); const initialCount = mockEventSourceInstances.length; act(() => { triggerOpen(); triggerError(); }); act(() => { vi.advanceTimersByTime(1500); // initial backoff = 1000ms }); expect(mockEventSourceInstances.length).toBeGreaterThan(initialCount); }); it("closes the old EventSource before reconnecting", () => { renderHook(() => useAgentStream()); act(() => { triggerOpen(); triggerError(); }); const closedInstance = mockEventSourceInstances[0]; expect(closedInstance?.close).toHaveBeenCalled(); }); }); // ========================================== // Cleanup on unmount // ========================================== describe("cleanup on unmount", () => { it("closes EventSource when the hook is unmounted", () => { const { unmount } = renderHook(() => useAgentStream()); const es = getLatestES(); unmount(); expect(es.close).toHaveBeenCalled(); }); it("does not attempt to reconnect after unmount", () => { const { unmount } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); triggerError(); }); const countBeforeUnmount = mockEventSourceInstances.length; unmount(); act(() => { vi.advanceTimersByTime(5000); }); // No new instances created after unmount expect(mockEventSourceInstances.length).toBe(countBeforeUnmount); }); }); // ========================================== // Dismiss agent // ========================================== describe("dismissAgent", () => { it("removes the agent from the map", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 }); }); act(() => { result.current.dismissAgent("agent-1"); }); expect(result.current.agents.has("agent-1")).toBe(false); }); it("is a no-op for unknown agentId", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" }); }); act(() => { result.current.dismissAgent("nonexistent-agent"); }); expect(result.current.agents.has("agent-1")).toBe(true); }); }); // ========================================== // Malformed event handling // ========================================== describe("malformed events", () => { it("ignores malformed JSON without throwing", () => { const { result } = renderHook(() => useAgentStream()); act(() => { triggerOpen(); // Dispatch raw bad JSON const es = getLatestES(); es.dispatchEvent("agent:spawned", "NOT JSON {{{"); }); // Should not crash, agents map stays empty expect(result.current.agents.size).toBe(0); }); }); });