import { createElement, useEffect } from "react"; import { act, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController"; import type { AgentState } from "@/features/agents/state/store"; import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation"; vi.mock("@/features/agents/operations/chatSendOperation", () => ({ sendChatMessageViaStudio: vi.fn(async () => undefined), })); const createAgent = (overrides?: Partial): AgentState => { const base: AgentState = { agentId: "agent-1", name: "Agent One", sessionKey: "agent:agent-1:studio:test-session", status: "idle", sessionCreated: true, awaitingUserInput: false, hasUnseenActivity: false, outputLines: [], lastResult: null, lastDiff: null, runId: "run-1", runStartedAt: null, streamText: null, thinkingTrace: null, latestOverride: null, latestOverrideKind: null, lastAssistantMessageAt: null, lastActivityAt: null, latestPreview: null, lastUserMessage: null, draft: "", queuedMessages: [], sessionSettingsSynced: true, historyLoadedAt: null, historyFetchLimit: null, historyFetchedCount: null, historyMaybeTruncated: false, toolCallingEnabled: true, showThinkingTraces: true, model: "openai/gpt-5", thinkingLevel: "medium", avatarSeed: "seed-1", avatarUrl: null, }; const merged = { ...base, ...(overrides ?? {}) }; return { ...merged, historyFetchLimit: merged.historyFetchLimit ?? null, historyFetchedCount: merged.historyFetchedCount ?? null, historyMaybeTruncated: merged.historyMaybeTruncated ?? false, }; }; type ControllerValue = ReturnType; type GatewayStatus = "disconnected" | "connecting" | "connected"; type InteractionDispatchAction = | { type: "updateAgent"; agentId: string; patch: Partial } | { type: "appendOutput"; agentId: string; line: string } | { type: "enqueueQueuedMessage"; agentId: string; message: string } | { type: "removeQueuedMessage"; agentId: string; index: number } | { type: "shiftQueuedMessage"; agentId: string; expectedMessage?: string }; type CallFn = (method: string, params: unknown) => Promise; type DispatchFn = (action: InteractionDispatchAction) => void; type ErrorFn = (message: string) => void; type RunTrackingFn = (runId?: string | null) => void; type HistoryInFlightFn = (sessionKey: string) => void; type AgentIdFn = (agentId: string) => void; type VoidFn = () => void; type RenderControllerContext = { getValue: () => ControllerValue; unmount: () => void; setAgents: (next: AgentState[]) => void; call: ReturnType>; dispatch: ReturnType>; setError: ReturnType>; clearRunTracking: ReturnType>; clearHistoryInFlight: ReturnType>; clearSpecialUpdateMarker: ReturnType>; clearSpecialLatestUpdateInFlight: ReturnType>; setInspectSidebarNull: ReturnType>; setMobilePaneChat: ReturnType>; }; const renderController = ( overrides?: Partial<{ status: GatewayStatus; agents: AgentState[]; call: CallFn; dispatch: DispatchFn; setError: ErrorFn; clearRunTracking: RunTrackingFn; clearHistoryInFlight: HistoryInFlightFn; clearSpecialUpdateMarker: AgentIdFn; clearSpecialLatestUpdateInFlight: AgentIdFn; setInspectSidebarNull: VoidFn; setMobilePaneChat: VoidFn; }> ): RenderControllerContext => { let agents = overrides?.agents ?? [createAgent()]; const call = vi.fn(overrides?.call ?? (async () => ({}))); const dispatch = vi.fn(overrides?.dispatch ?? (() => undefined)); const setError = vi.fn(overrides?.setError ?? (() => undefined)); const clearRunTracking = vi.fn( overrides?.clearRunTracking ?? (() => undefined) ); const clearHistoryInFlight = vi.fn( overrides?.clearHistoryInFlight ?? (() => undefined) ); const clearSpecialUpdateMarker = vi.fn( overrides?.clearSpecialUpdateMarker ?? (() => undefined) ); const clearSpecialLatestUpdateInFlight = vi.fn( overrides?.clearSpecialLatestUpdateInFlight ?? (() => undefined) ); const setInspectSidebarNull = vi.fn( overrides?.setInspectSidebarNull ?? (() => undefined) ); const setMobilePaneChat = vi.fn(overrides?.setMobilePaneChat ?? (() => undefined)); const valueRef: { current: ControllerValue | null } = { current: null }; const Probe = ({ onValue, }: { onValue: (value: ControllerValue) => void; }) => { const value = useChatInteractionController({ client: { call, }, status: overrides?.status ?? "connected", agents, dispatch, setError, getAgents: () => agents, clearRunTracking, clearHistoryInFlight, clearSpecialUpdateMarker, clearSpecialLatestUpdateInFlight, setInspectSidebarNull, setMobilePaneChat, }); useEffect(() => { onValue(value); }, [onValue, value]); return createElement("div", { "data-testid": "probe" }, "ok"); }; const rendered = render( createElement(Probe, { onValue: (value) => { valueRef.current = value; }, }) ); return { getValue: () => { if (!valueRef.current) throw new Error("controller value unavailable"); return valueRef.current; }, unmount: () => { rendered.unmount(); }, setAgents: (next) => { agents = next; rendered.rerender( createElement(Probe, { onValue: (value) => { valueRef.current = value; }, }) ); }, call, dispatch, setError, clearRunTracking, clearHistoryInFlight, clearSpecialUpdateMarker, clearSpecialLatestUpdateInFlight, setInspectSidebarNull, setMobilePaneChat, }; }; describe("useChatInteractionController", () => { const mockedSendChatMessageViaStudio = vi.mocked(sendChatMessageViaStudio); const originalRaf = globalThis.requestAnimationFrame; const originalCaf = globalThis.cancelAnimationFrame; beforeEach(() => { vi.useFakeTimers(); mockedSendChatMessageViaStudio.mockReset(); mockedSendChatMessageViaStudio.mockResolvedValue(undefined); }); afterEach(() => { vi.useRealTimers(); globalThis.requestAnimationFrame = originalRaf; globalThis.cancelAnimationFrame = originalCaf; vi.restoreAllMocks(); }); it("flushes pending draft and cancels debounce timer", async () => { const ctx = renderController(); act(() => { ctx.getValue().handleDraftChange("agent-1", "first"); ctx.getValue().handleDraftChange("agent-1", "second"); }); expect(ctx.dispatch).not.toHaveBeenCalled(); act(() => { ctx.getValue().flushPendingDraft("agent-1"); }); expect(ctx.dispatch).toHaveBeenCalledWith({ type: "updateAgent", agentId: "agent-1", patch: { draft: "second" }, }); await vi.advanceTimersByTimeAsync(1000); const draftUpdates = ctx.dispatch.mock.calls .map(([action]: [InteractionDispatchAction]) => action) .filter( (action) => action.type === "updateAgent" && action.agentId === "agent-1" && action.patch?.draft === "second" ); expect(draftUpdates).toHaveLength(1); }); it("clears pending draft timer/value and live patch before send", async () => { let queuedFrame: ((time: number) => void) | null = null; globalThis.requestAnimationFrame = vi.fn((callback: (time: number) => void) => { queuedFrame = callback; return 77; }); globalThis.cancelAnimationFrame = vi.fn(); const ctx = renderController(); act(() => { ctx.getValue().handleDraftChange("agent-1", "queued draft"); ctx.getValue().queueLivePatch("agent-1", { streamText: "pending stream" }); }); await act(async () => { await ctx.getValue().handleSend("agent-1", "session-1", " hello world "); }); expect(mockedSendChatMessageViaStudio).toHaveBeenCalledWith( expect.objectContaining({ agentId: "agent-1", sessionKey: "session-1", message: "hello world", }) ); expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(77); await vi.advanceTimersByTimeAsync(300); expect( ctx.dispatch.mock.calls.some( ([action]: [InteractionDispatchAction]) => action.type === "updateAgent" && action.agentId === "agent-1" && action.patch?.draft === "queued draft" ) ).toBe(false); if (queuedFrame) { act(() => { queuedFrame?.(0); }); } expect( ctx.dispatch.mock.calls.some( ([action]: [InteractionDispatchAction]) => action.type === "updateAgent" && action.agentId === "agent-1" && action.patch?.streamText === "pending stream" ) ).toBe(false); }); it("queues messages instead of sending while the agent is running", async () => { const ctx = renderController({ agents: [createAgent({ status: "running", queuedMessages: [] })], }); await act(async () => { await ctx.getValue().handleSend("agent-1", "session-1", " follow up "); }); expect(mockedSendChatMessageViaStudio).not.toHaveBeenCalled(); expect(ctx.dispatch).toHaveBeenCalledWith({ type: "enqueueQueuedMessage", agentId: "agent-1", message: "follow up", }); }); it("drains one queued message when an agent becomes idle", async () => { const ctx = renderController({ agents: [createAgent({ status: "running", queuedMessages: ["next message"] })], }); act(() => { ctx.setAgents([ createAgent({ status: "idle", sessionKey: "agent:agent-1:studio:drain", queuedMessages: ["next message"], }), ]); }); await act(async () => { await Promise.resolve(); }); expect(ctx.dispatch).toHaveBeenCalledWith({ type: "shiftQueuedMessage", agentId: "agent-1", expectedMessage: "next message", }); expect(mockedSendChatMessageViaStudio).toHaveBeenCalledWith( expect.objectContaining({ agentId: "agent-1", sessionKey: "agent:agent-1:studio:drain", message: "next message", }) ); }); it("does not drain queued messages while disconnected", async () => { const ctx = renderController({ status: "disconnected", agents: [createAgent({ status: "idle", queuedMessages: ["keep queued"] })], }); await act(async () => { await Promise.resolve(); }); expect(mockedSendChatMessageViaStudio).not.toHaveBeenCalled(); expect( ctx.dispatch.mock.calls.some( ([action]: [InteractionDispatchAction]) => action.type === "shiftQueuedMessage" ) ).toBe(false); }); it("removes a queued message by index", () => { const ctx = renderController({ agents: [createAgent({ queuedMessages: ["first", "second"] })], }); act(() => { ctx.getValue().removeQueuedMessage("agent-1", 0); }); expect(ctx.dispatch).toHaveBeenCalledWith({ type: "removeQueuedMessage", agentId: "agent-1", index: 0, }); }); it("deduplicates stop-run while busy and clears busy state after success", async () => { let resolveAbort: ((value?: void | PromiseLike) => void) | undefined; const abortPromise = new Promise((resolve) => { resolveAbort = resolve; }); const call = vi.fn(async (method: string) => { if (method === "chat.abort") { await abortPromise; return {}; } return {}; }); const ctx = renderController({ call }); let firstCall: Promise | null = null; act(() => { firstCall = ctx.getValue().handleStopRun("agent-1", " session-1 "); }); expect(ctx.getValue().stopBusyAgentId).toBe("agent-1"); await act(async () => { await ctx.getValue().handleStopRun("agent-1", "session-1"); }); expect(call).toHaveBeenCalledTimes(1); resolveAbort?.(); await act(async () => { await firstCall; }); expect(ctx.getValue().stopBusyAgentId).toBeNull(); }); it("reports stop-run failures and clears busy state", async () => { const logSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const ctx = renderController({ call: vi.fn(async (method: string) => { if (method === "chat.abort") { throw new Error("abort failed"); } return {}; }), }); await act(async () => { await ctx.getValue().handleStopRun("agent-1", "session-1"); }); expect(ctx.setError).toHaveBeenCalledWith("abort failed"); expect(logSpy).toHaveBeenCalledWith("abort failed"); expect(ctx.dispatch).toHaveBeenCalledWith({ type: "appendOutput", agentId: "agent-1", line: "Stop failed: abort failed", }); expect(ctx.getValue().stopBusyAgentId).toBeNull(); }); it("runs new-session side effects in sequence and updates agent state", async () => { const order: string[] = []; const call = vi.fn(async (method: string) => { if (method === "sessions.reset") { order.push("sessions.reset"); } return {}; }); const dispatch = vi.fn((action) => { if (action.type === "updateAgent") { order.push("dispatch:updateAgent"); } }); const clearRunTracking = vi.fn(() => { order.push("clearRunTracking"); }); const clearHistoryInFlight = vi.fn(() => { order.push("clearHistoryInFlight"); }); const clearSpecialUpdateMarker = vi.fn(() => { order.push("clearSpecialUpdateMarker"); }); const clearSpecialLatestUpdateInFlight = vi.fn(() => { order.push("clearSpecialLatestUpdateInFlight"); }); const setInspectSidebarNull = vi.fn(() => { order.push("setInspectSidebarNull"); }); const setMobilePaneChat = vi.fn(() => { order.push("setMobilePaneChat"); }); const ctx = renderController({ call, dispatch, clearRunTracking, clearHistoryInFlight, clearSpecialUpdateMarker, clearSpecialLatestUpdateInFlight, setInspectSidebarNull, setMobilePaneChat, agents: [ createAgent({ agentId: "agent-1", runId: "run-42", sessionKey: " session-42 ", }), ], }); await act(async () => { await ctx.getValue().handleNewSession("agent-1"); }); expect(call).toHaveBeenCalledWith("sessions.reset", { key: "session-42" }); expect(order).toEqual([ "sessions.reset", "clearRunTracking", "clearHistoryInFlight", "clearSpecialUpdateMarker", "clearSpecialLatestUpdateInFlight", "dispatch:updateAgent", "setInspectSidebarNull", "setMobilePaneChat", ]); }); it("appends output when new-session fails", async () => { const ctx = renderController({ agents: [ createAgent({ agentId: "agent-1", sessionKey: " ", }), ], }); await act(async () => { await ctx.getValue().handleNewSession("agent-1"); }); expect(ctx.setError).toHaveBeenCalledWith("Missing session key for agent."); expect(ctx.dispatch).toHaveBeenCalledWith({ type: "appendOutput", agentId: "agent-1", line: "New session failed: Missing session key for agent.", }); }); it("cleans up draft timers and queued frame on unmount", async () => { globalThis.requestAnimationFrame = vi.fn(() => 555); globalThis.cancelAnimationFrame = vi.fn(); const clearTimeoutSpy = vi.spyOn(window, "clearTimeout"); const ctx = renderController(); act(() => { ctx.getValue().handleDraftChange("agent-1", "queued"); ctx.getValue().queueLivePatch("agent-1", { streamText: "delta" }); }); ctx.unmount(); expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(555); expect(clearTimeoutSpy).toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(300); expect( ctx.dispatch.mock.calls.some( ([action]: [InteractionDispatchAction]) => action.type === "updateAgent" && action.agentId === "agent-1" && action.patch?.draft === "queued" ) ).toBe(false); }); });