import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { render, screen, fireEvent, act, cleanup, } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted // above the imports) can expose the captured useChat callbacks / transport and // the spies back to the test body. const h = vi.hoisted(() => ({ state: { status: "streaming" as string, messages: [] as unknown[], // Mirrors the SDK: on an isError finish (incl. a network drop, which is ALWAYS // isError+isDisconnect) the SDK ALSO sets useChat `error`. Realistic tests set // this so the banner-priority (errorView vs recovery phase) is actually exercised. error: null as null | { message: string }, onFinish: null as null | ((arg: Record) => void), sendMessage: vi.fn(), stop: vi.fn(), setMessages: vi.fn(), resumeStream: vi.fn(), seededMessages: null as null | unknown[], transport: null as null | { prepareSendMessagesRequest?: (arg: { messages: unknown[]; body: Record; }) => { body: Record }; prepareReconnectToStreamRequest?: () => { api?: string }; fetch?: ( input: unknown, init?: { method?: string; body?: unknown }, ) => Promise; }, // getRun mock (POST /ai-chat/run run-fact). Default: no active run. getRun: vi.fn( async (_chatId?: string) => ({ run: null, message: null }) as { run: { id: string; status: string } | null; message: unknown; }, ), }, })); vi.mock("@ai-sdk/react", () => ({ useChat: (opts: { messages?: unknown[]; onFinish?: (arg: Record) => void; }) => { h.state.onFinish = opts.onFinish ?? null; h.state.seededMessages = opts.messages ?? null; return { messages: h.state.messages, sendMessage: h.state.sendMessage, status: h.state.status, stop: h.state.stop, error: h.state.error, setMessages: h.state.setMessages, resumeStream: h.state.resumeStream, }; }, })); vi.mock("ai", () => { let counter = 0; return { generateId: () => `gid-${counter++}`, DefaultChatTransport: class { constructor(opts: Record) { h.state.transport = opts as never; } }, }; }); vi.mock("@/features/ai-chat/queries/ai-chat-query.ts", () => ({ AI_CHAT_MESSAGES_RQ_KEY: (chatId: string) => ["ai-chat-messages", chatId], })); // The run-fact service (POST /ai-chat/run). Mocked so a user-tail mount and the // supersede verify do not hit axios. vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({ getRun: (chatId: string) => h.state.getRun(chatId), })); vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({ default: () =>
, })); vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({ default: ({ onQueue, onStop, onSend, }: { onQueue: (text: string) => void; onStop: () => void; onSend: (text: string) => void; }) => ( <> ), })); import ChatThread from "./chat-thread"; import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts"; function row( id: string, role: string, status?: string, text = "", ): IAiChatMessageRow { return { id, role, content: text, status, createdAt: "2026-01-01T00:00:00Z" }; } function renderThread(props?: { chatId?: string | null; initialRows?: IAiChatMessageRow[]; autonomousRunsEnabled?: boolean; }) { const onTurnFinished = vi.fn(); const onResumeFallback = vi.fn(); const onServerStop = vi.fn(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); const { unmount } = render( , ); return { onTurnFinished, onResumeFallback, onServerStop, invalidateSpy, unmount }; } function resetState() { h.state.status = "streaming"; h.state.messages = []; h.state.error = null; h.state.onFinish = null; h.state.seededMessages = null; h.state.transport = null; h.state.sendMessage.mockClear(); h.state.stop.mockClear(); h.state.setMessages.mockClear(); h.state.resumeStream.mockClear(); h.state.getRun.mockReset(); h.state.getRun.mockResolvedValue({ run: null, message: null }); } const streamingTail = () => [ row("u1", "user", undefined, "hi"), row("a1", "assistant", "streaming", "partial"), ]; const settledTail = () => [ row("u1", "user", undefined, "hi"), row("a1", "assistant", "succeeded", "done"), ]; const userTail = () => [row("u1", "user", undefined, "hi")]; // ----------------------------------------------------------------------------- // Send now (#198) — local send + #488 commit 5 CAS supersede. // ----------------------------------------------------------------------------- describe("ChatThread — send now", () => { beforeEach(resetState); afterEach(cleanup); it("sends immediately (no interrupt) when not streaming", () => { h.state.status = "ready"; renderThread({ initialRows: settledTail() }); fireEvent.click(screen.getByTestId("queue-btn")); fireEvent.click(screen.getByLabelText("Send now")); expect(h.state.stop).not.toHaveBeenCalled(); expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" }); }); // Drive a REAL live LOCAL stream with an active run-fact: a runId-bearing // assistant message (mount adoption -> RUN_FACT) + a local send (SEND_LOCAL -> // FSM `sending`, ownership local). sendNow reads LIVENESS off the FSM phase // (LOW-1), so a faked SDK status alone is not enough — the stream must be real. function startLocalStreamWithRun() { h.state.status = "streaming"; h.state.messages = [ { id: "a1", role: "assistant", parts: [{ type: "text", text: "x" }], metadata: { runId: "run-1" }, }, ]; const view = renderThread({ autonomousRunsEnabled: true, initialRows: settledTail(), }); fireEvent.click(screen.getByTestId("send-btn")); // SEND_LOCAL -> FSM `sending` h.state.sendMessage.mockClear(); return view; } it("#488 commit 5 / F1: sendNow ABORTS stream A first, then sends B (CAS) only after A finalizes", async () => { startLocalStreamWithRun(); fireEvent.click(screen.getByTestId("queue-btn")); fireEvent.click(screen.getByLabelText("Send now")); // F1: A is ABORTED, and B is NOT sent yet (no overlap). expect(h.state.stop).toHaveBeenCalledTimes(1); expect(h.state.sendMessage).not.toHaveBeenCalled(); // A's onFinish fires -> B is scheduled on a microtask (after A finalizes). await act(async () => { h.state.onFinish?.({ message: { id: "a1", role: "assistant", parts: [] }, isAbort: true, isDisconnect: false, isError: false, }); await Promise.resolve(); }); expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" }); // B's POST carries supersede:{runId} (read-and-cleared once). const prep = h.state.transport!.prepareSendMessagesRequest!; expect(prep({ messages: [], body: {} }).body.supersede).toEqual({ runId: "run-1", }); expect(prep({ messages: [], body: {} }).body.supersede).toBeUndefined(); }); it("#488 F1: a superseded stream's LATE disconnect does NOT falsely reconnect (epoch stamp drops it)", async () => { // MUTATION-VERIFY: drop `epoch: stampEpoch` from the supersede-branch // FINISH_DISCONNECT dispatch and this goes red (A's disconnect -> reconnecting). startLocalStreamWithRun(); fireEvent.click(screen.getByTestId("queue-btn")); fireEvent.click(screen.getByLabelText("Send now")); // -> superseding, aborts A // A ends via a REAL network drop { isError:true, isDisconnect:true } + error set // (the server CAS closed it). The OLD overlap bug would route the LIVE new run // into a false reconnect banner. h.state.error = { message: "Failed to fetch" }; await act(async () => { h.state.onFinish?.({ message: { id: "a1", role: "assistant", parts: [{ type: "text", text: "x" }] }, isAbort: false, isDisconnect: true, isError: true, }); await Promise.resolve(); }); expect(screen.queryByText(/reconnecting/i)).toBeNull(); // ...and B was still sent. expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" }); }); it("#488 commit 5: a supersede POST that 409s SUPERSEDE_TIMEOUT is returned as-is (NO retry ladder)", async () => { startLocalStreamWithRun(); fireEvent.click(screen.getByTestId("queue-btn")); fireEvent.click(screen.getByLabelText("Send now")); // -> superseding, arms body // Consume the supersede body (as the SDK would) so the POST is the CAS one. h.state.transport!.prepareSendMessagesRequest!({ messages: [], body: {} }); const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ code: "SUPERSEDE_TIMEOUT" }), { status: 409, }), ); vi.stubGlobal("fetch", fetchMock); let res!: Response; await act(async () => { res = (await h.state.transport!.fetch!("http://x", { method: "POST", body: "{}", })) as Response; }); // Exactly ONE fetch (the old bounded 409 retry ladder is gone). expect(fetchMock).toHaveBeenCalledTimes(1); expect(res.status).toBe(409); }); it("#488 LOW-1: sendNow when A has ALREADY settled (statusRef stale) sends immediately, not stuck superseding", () => { startLocalStreamWithRun(); // FSM `sending`, mock status "streaming" // A settles cleanly -> FSM `idle`. The mock status stays "streaming" (render- // lagged), which the OLD statusRef gate would misread as "still live". act(() => { h.state.onFinish?.({ message: { id: "a1", role: "assistant", parts: [{ type: "text", text: "done" }] }, isAbort: false, isDisconnect: false, isError: false, }); }); h.state.sendMessage.mockClear(); h.state.stop.mockClear(); fireEvent.click(screen.getByTestId("queue-btn")); fireEvent.click(screen.getByLabelText("Send now")); // The FSM phase (idle) is authoritative -> B is sent IMMEDIATELY, not routed // into an aborting supersede that would strand the machine (A's second onFinish // never comes) and lose the message. expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" }); expect(h.state.stop).not.toHaveBeenCalled(); }); it("#488 LOW-2: a second Send now while a supersede is in flight is a no-op (first message not lost)", () => { startLocalStreamWithRun(); fireEvent.click(screen.getByTestId("queue-btn")); // X fireEvent.click(screen.getByTestId("queue-btn")); // Y expect(screen.getAllByLabelText("Send now")).toHaveLength(2); fireEvent.click(screen.getAllByLabelText("Send now")[0]); // supersede X -> superseding expect(h.state.stop).toHaveBeenCalledTimes(1); // Y is still queued; a second Send now must NOT clobber X or re-abort. const remaining = screen.getAllByLabelText("Send now"); expect(remaining).toHaveLength(1); fireEvent.click(remaining[0]); expect(h.state.stop).toHaveBeenCalledTimes(1); // no second abort // Y is still queued (kept, not lost, not sent). expect(screen.getAllByLabelText("Remove queued message")).toHaveLength(1); }); it("Stop then a REAL network-drop finish exits to idle (honor-in-stopping), NOT a false reconnect", () => { // Regression for the disconnect-first reorder: on the STOP path, even a drop- // form finish { isError:true, isDisconnect:true } arriving in `stopping` must be // HONORED (reducer) and exit to idle — it must NOT enter the reconnect ladder. startLocalStreamWithRun(); // live local stream, autonomous fireEvent.click(screen.getByLabelText("Stop")); // STOP_REQUESTED -> stopping h.state.error = { message: "Failed to fetch" }; act(() => { h.state.onFinish?.({ message: { id: "a1", role: "assistant", parts: [] }, isAbort: false, isDisconnect: true, isError: true, }); }); expect(screen.queryByText(/reconnecting/i)).toBeNull(); }); it("Send now is HIDDEN while observing a resumed run and VISIBLE on a local stream", () => { // Resumed (mount attach) -> observer -> hidden. renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() }); fireEvent.click(screen.getByTestId("queue-btn")); expect(screen.queryByLabelText("Send now")).toBeNull(); // Local stream (no resume) -> visible. cleanup(); resetState(); renderThread({ initialRows: [] }); fireEvent.click(screen.getByTestId("queue-btn")); expect(screen.getByLabelText("Send now")).toBeTruthy(); }); }); // ----------------------------------------------------------------------------- // onFinish flush gated on mount (#486). // ----------------------------------------------------------------------------- describe("ChatThread — onFinish flush gated on mount (#486)", () => { beforeEach(resetState); afterEach(cleanup); it("a clean onFinish WHILE MOUNTED flushes the queued message", () => { renderThread(); fireEvent.click(screen.getByTestId("queue-btn")); expect(h.state.sendMessage).not.toHaveBeenCalled(); act(() => { h.state.onFinish?.({ message: { id: "a", role: "assistant", parts: [] }, isAbort: false, isDisconnect: false, isError: false, }); }); expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" }); }); it("a clean onFinish AFTER unmount does NOT flush (no ghost send)", () => { const { unmount } = renderThread(); fireEvent.click(screen.getByTestId("queue-btn")); h.state.sendMessage.mockClear(); unmount(); act(() => { h.state.onFinish?.({ message: { id: "a", role: "assistant", parts: [] }, isAbort: false, isDisconnect: false, isError: false, }); }); expect(h.state.sendMessage).not.toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------------- // Turn-end decision (onFinish). // ----------------------------------------------------------------------------- describe("ChatThread — turn-end decision (onFinish)", () => { beforeEach(resetState); afterEach(cleanup); function finishWith(flags: { isAbort?: boolean; isDisconnect?: boolean; isError?: boolean; }) { const { onTurnFinished } = renderThread(); fireEvent.click(screen.getByTestId("queue-btn")); // Mirror the SDK: any isError finish (a provider error or a drop) also sets // useChat `error` (a drop message triggers the connection-lost classification). if (flags.isError) h.state.error = { message: flags.isDisconnect ? "Failed to fetch" : "500: boom" }; act(() => { h.state.onFinish?.({ message: { id: "a", role: "assistant", parts: [] }, isAbort: false, isDisconnect: false, isError: false, ...flags, }); }); return { onTurnFinished }; } it("CONTINUES — flushes the next queued message on a clean finish", () => { finishWith({}); expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" }); expect(screen.queryByText("Response stopped.")).toBeNull(); }); it("ENDS — keeps the queue on a user abort and shows the stopped notice", () => { finishWith({ isAbort: true }); expect(h.state.sendMessage).not.toHaveBeenCalled(); expect(screen.getByText("Response stopped.")).toBeTruthy(); }); it("ENDS — keeps the queue on a REAL disconnect (non-autonomous) and shows the notice", () => { // Real SDK drop form: { isError:true, isDisconnect:true }. With the buggy // isError-first order this would fall to the terminal error branch (no notice). finishWith({ isDisconnect: true, isError: true }); expect(h.state.sendMessage).not.toHaveBeenCalled(); expect( screen.getByText("Connection lost — the answer was interrupted."), ).toBeTruthy(); }); it("ENDS — keeps the queue on a NON-disconnect stream error (no notice)", () => { // A provider error is { isError:true, isDisconnect:false } — terminal. finishWith({ isError: true }); expect(h.state.sendMessage).not.toHaveBeenCalled(); expect(screen.queryByText("Response stopped.")).toBeNull(); }); it("notifies the parent on EVERY terminal outcome", () => { for (const flags of [ {}, { isAbort: true }, { isDisconnect: true, isError: true }, { isError: true }, ]) { cleanup(); resetState(); const { onTurnFinished } = finishWith(flags); expect(onTurnFinished).toHaveBeenCalled(); } }); }); // ----------------------------------------------------------------------------- // editor selection wiring (#388). // ----------------------------------------------------------------------------- describe("ChatThread — editor selection wiring (#388)", () => { beforeEach(resetState); afterEach(cleanup); function renderWithSelection(props: { openPage?: { id: string; title: string } | null; getEditorSelection?: () => unknown; }) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); render( , ); } it("nests the snapshot into openPage.selection at send time", () => { const selection = { text: "fix this", blockIds: ["b1"], before: "a " }; renderWithSelection({ openPage: { id: "p1", title: "Doc" }, getEditorSelection: () => selection, }); const prep = h.state.transport!.prepareSendMessagesRequest!; const openPage = prep({ messages: [], body: {} }).body.openPage as Record< string, unknown >; expect(openPage).toEqual({ id: "p1", title: "Doc", selection }); }); it("sends selection: null when the getter returns null", () => { renderWithSelection({ openPage: { id: "p1", title: "Doc" }, getEditorSelection: () => null, }); const prep = h.state.transport!.prepareSendMessagesRequest!; const openPage = prep({ messages: [], body: {} }).body.openPage as Record< string, unknown >; expect(openPage).toEqual({ id: "p1", title: "Doc", selection: null }); }); it("does not consult the getter on a non-page route (openPage null)", () => { const getter = vi.fn(() => ({ text: "sel" })); renderWithSelection({ openPage: null, getEditorSelection: getter }); const prep = h.state.transport!.prepareSendMessagesRequest!; expect(prep({ messages: [], body: {} }).body.openPage).toBeNull(); expect(getter).not.toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------------- // Resume (attach) machinery (#184) + #488 commit 4b run-fact gating. // ----------------------------------------------------------------------------- describe("ChatThread — resume (attach) machinery", () => { beforeEach(resetState); afterEach(cleanup); it("resumes on mount for a STREAMING tail (the streaming status is the run-fact)", () => { renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() }); expect(h.state.resumeStream).toHaveBeenCalledTimes(1); }); it("does NOT resume for a settled tail, flag off, or no chatId", () => { renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() }); expect(h.state.resumeStream).not.toHaveBeenCalled(); cleanup(); resetState(); renderThread({ autonomousRunsEnabled: false, initialRows: streamingTail() }); expect(h.state.resumeStream).not.toHaveBeenCalled(); cleanup(); resetState(); renderThread({ autonomousRunsEnabled: true, chatId: null, initialRows: streamingTail(), }); expect(h.state.resumeStream).not.toHaveBeenCalled(); }); it("#488 commit 4b: a USER tail resumes ONLY when POST /run confirms an active run", async () => { h.state.getRun.mockResolvedValue({ run: { id: "run-1", status: "running" }, message: null, }); renderThread({ autonomousRunsEnabled: true, initialRows: userTail() }); await act(async () => { await Promise.resolve(); }); expect(h.state.getRun).toHaveBeenCalledWith("c1"); expect(h.state.resumeStream).toHaveBeenCalledTimes(1); }); it("#488 commit 4b: a USER tail with NO active run does NOT resume and does NOT arm the poll", async () => { h.state.getRun.mockResolvedValue({ run: null, message: null }); const { onResumeFallback } = renderThread({ autonomousRunsEnabled: true, initialRows: userTail(), }); await act(async () => { await Promise.resolve(); }); expect(h.state.resumeStream).not.toHaveBeenCalled(); expect(onResumeFallback).not.toHaveBeenCalledWith(true); }); it("#488 F2: a local send DURING the mount getRun round-trip is NOT hijacked by the late attach", async () => { // getRun stays pending while the user sends locally; its late resolve would // otherwise ATTACH_START and flip the local turn into an observer-attach. let resolveGetRun!: (v: { run: { id: string; status: string } | null; message: unknown; }) => void; h.state.getRun.mockReturnValue( new Promise((r) => { resolveGetRun = r; }), ); renderThread({ autonomousRunsEnabled: true, initialRows: userTail() }); // Local send in flight of getRun -> SEND_LOCAL (phase sending, ownership local). fireEvent.click(screen.getByTestId("send-btn")); expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "typed text" }); // getRun resolves with an ACTIVE run -> the F2 guard ignores ATTACH_START. await act(async () => { resolveGetRun({ run: { id: "run-1", status: "running" }, message: null }); await Promise.resolve(); }); // The local turn was NOT hijacked into a resume/attach. expect(h.state.resumeStream).not.toHaveBeenCalled(); }); it("strips the streaming tail from the seed, keeps a user tail whole", () => { renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() }); expect(h.state.seededMessages).toHaveLength(1); cleanup(); resetState(); renderThread({ autonomousRunsEnabled: true, initialRows: userTail() }); expect(h.state.seededMessages).toHaveLength(1); }); it("builds the attach URL with expect=live&anchor only for a stripped streaming tail", () => { renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() }); expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe( "/api/ai-chat/runs/c1/stream?expect=live&anchor=a1", ); cleanup(); resetState(); renderThread({ autonomousRunsEnabled: true, initialRows: userTail() }); expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe( "/api/ai-chat/runs/c1/stream", ); }); async function attachFetch(response: unknown, reject = false) { vi.stubGlobal( "fetch", reject ? vi.fn().mockRejectedValue(response) : vi.fn().mockResolvedValue(response), ); await act(async () => { await h.state .transport!.fetch!("http://x", { method: "GET" }) .catch(() => undefined); }); } it("204 on a streaming tail: restore + invalidate + onResumeFallback(true)", async () => { const { onResumeFallback, invalidateSpy } = renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail(), }); await attachFetch({ status: 204, ok: false }); expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["ai-chat-messages", "c1"], }); expect(onResumeFallback).toHaveBeenCalledWith(true); }); it("F7 restart-survival: a 500 attach failure restores the row AND arms the poll", async () => { const { onResumeFallback, invalidateSpy } = renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail(), }); await attachFetch({ status: 500, ok: false }); expect(h.state.setMessages).toHaveBeenCalledTimes(1); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["ai-chat-messages", "c1"], }); expect(onResumeFallback).toHaveBeenCalledWith(true); }); it("F7 restart-survival: a network throw restores the row AND arms the poll", async () => { const { onResumeFallback, invalidateSpy } = renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail(), }); await attachFetch(new Error("network down"), true); expect(h.state.setMessages).toHaveBeenCalledTimes(1); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["ai-chat-messages", "c1"], }); expect(onResumeFallback).toHaveBeenCalledWith(true); }); it("unmount during a pending attach aborts the controller and gates late callbacks (epoch I1)", async () => { const { onResumeFallback, invalidateSpy, unmount } = renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail(), }); let abortSeen = false; let resolveFetch!: (v: unknown) => void; vi.stubGlobal( "fetch", vi.fn().mockImplementation((_input: unknown, init: RequestInit) => { init.signal?.addEventListener("abort", () => { abortSeen = true; }); return new Promise((res) => { resolveFetch = res; }); }), ); let pending!: Promise; act(() => { pending = h.state.transport!.fetch!("http://x", { method: "GET" }); }); unmount(); // DISPOSE aborts the attach + bumps the epoch expect(abortSeen).toBe(true); onResumeFallback.mockClear(); invalidateSpy.mockClear(); await act(async () => { resolveFetch({ status: 204, ok: false }); await pending; }); // The stale (superseded-epoch) outcome is dropped. expect(onResumeFallback).not.toHaveBeenCalledWith(true); expect(invalidateSpy).not.toHaveBeenCalled(); }); it("a resumed (observer) turn's onFinish does NOT flush the queue", () => { renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() }); fireEvent.click(screen.getByTestId("queue-btn")); act(() => { h.state.onFinish?.({ message: { id: "a1", role: "assistant", parts: [{ type: "text", text: "streamed answer" }], }, isAbort: false, isDisconnect: false, isError: false, }); }); expect(h.state.sendMessage).not.toHaveBeenCalled(); }); it("an empty resumed message (starved replay) restores the row AND arms the poll", () => { h.state.status = "ready"; const { onResumeFallback } = renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail(), }); h.state.setMessages.mockClear(); onResumeFallback.mockClear(); act(() => { h.state.onFinish?.({ message: { id: "a1", role: "assistant", parts: [] }, isAbort: false, isDisconnect: false, isError: false, }); }); expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore expect(onResumeFallback).toHaveBeenCalledWith(true); // arm }); it("handleStop aborts the attach controller and calls onServerStop", () => { const { onServerStop } = renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail(), }); let abortSeen = false; vi.stubGlobal( "fetch", vi.fn().mockImplementation((_input: unknown, init: RequestInit) => { init.signal?.addEventListener("abort", () => { abortSeen = true; }); return new Promise(() => undefined); }), ); act(() => { void h.state.transport!.fetch!("http://x", { method: "GET" }); }); fireEvent.click(screen.getByLabelText("Stop")); expect(abortSeen).toBe(true); expect(onServerStop).toHaveBeenCalledWith("c1"); }); }); // ----------------------------------------------------------------------------- // Live reconnect (#430) + #488 commits 2/3 + stalled (4a). Fake timers. // ----------------------------------------------------------------------------- describe("ChatThread — live reconnect + stalled", () => { const liveMsg = { id: "a2", role: "assistant", parts: [{ type: "text", text: "partial live answer" }], }; beforeEach(() => { resetState(); h.state.status = "ready"; vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); cleanup(); }); // A REAL live SSE drop. ai@6.0.207 emits BOTH { isError:true, isDisconnect:true } // for a network TypeError AND sets useChat `error` — NOT the { isError:false, // error:null } form the old tests fed. This is the form browser QA hit; with the // buggy isError-first routing OR without the errorView render-gate these tests go // red (a real drop surfaces the terminal error banner, masking the reconnect // ladder). MUTATION-VERIFY of disconnect-first + the errorView phase-gate. function disconnect(message: unknown = liveMsg) { h.state.error = { message: "Failed to fetch" }; // the SDK sets error on the drop act(() => { h.state.onFinish?.({ message, isAbort: false, isDisconnect: true, isError: true, }); }); } function renderLive() { const view = renderThread({ autonomousRunsEnabled: true, initialRows: settledTail(), }); expect(h.state.resumeStream).not.toHaveBeenCalled(); return view; } function advanceToAttempt(attempt: number) { act(() => { vi.advanceTimersByTime(1000 * 2 ** (attempt - 1)); }); } async function reconnect(response: unknown) { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); await act(async () => { await h.state.transport!.fetch!("http://x", { method: "GET" }); }); } it("a live disconnect starts a backoff reconnect (banner + resumeStream after backoff)", () => { renderLive(); disconnect(); expect(screen.getByText(/reconnecting/i)).toBeTruthy(); expect(h.state.resumeStream).not.toHaveBeenCalled(); advanceToAttempt(1); expect(h.state.resumeStream).toHaveBeenCalledTimes(1); expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe( "/api/ai-chat/runs/c1/stream?expect=live&anchor=a2", ); }); it("#488 (browser QA): the reconnect banner is SHOWN, not masked by the residual useChat error", () => { // The drop sets useChat `error` (real SDK), and the terminal errorView describes // it ("Lost connection to the server"). The FSM phase-gate must let the // `reconnecting` banner WIN over that residual error. MUTATION-VERIFY: revert the // errorView phase-gate (show errorView whenever error is set) and the terminal // banner masks "reconnecting…" -> red. renderLive(); disconnect(); expect(h.state.error).not.toBeNull(); // the SDK error IS set during recovery expect(screen.getByText(/reconnecting/i)).toBeTruthy(); // The terminal "Lost connection… reload" banner must NOT be showing. expect(screen.queryByText(/reload and try again/i)).toBeNull(); }); it("#488 commit 2: a disconnect BEFORE the first assistant frame reconnects with NO anchor", () => { renderLive(); disconnect(null); // no assistant message yet (pre-first-frame break) expect(screen.getByText(/reconnecting/i)).toBeTruthy(); expect( screen.queryByText("Connection lost — the answer was interrupted."), ).toBeNull(); advanceToAttempt(1); expect(h.state.resumeStream).toHaveBeenCalledTimes(1); expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe( "/api/ai-chat/runs/c1/stream", ); }); it("a live re-attach (2xx) clears the reconnect banner", async () => { renderLive(); disconnect(); advanceToAttempt(1); await reconnect({ status: 200, ok: true }); expect(screen.queryByText(/reconnecting/i)).toBeNull(); }); it("a 204 arms the degraded poll and backs off to the next attempt", async () => { const { onResumeFallback } = renderLive(); disconnect(); advanceToAttempt(1); expect(h.state.resumeStream).toHaveBeenCalledTimes(1); await reconnect({ status: 204, ok: false }); expect(onResumeFallback).toHaveBeenCalledWith(true); expect(screen.getByText(/reconnecting.*2\/5/i)).toBeTruthy(); advanceToAttempt(2); expect(h.state.resumeStream).toHaveBeenCalledTimes(2); }); it("exhausts the attempt limit into a manual Retry, which restarts the sequence", async () => { renderLive(); disconnect(); for (let n = 1; n <= 5; n++) { advanceToAttempt(n); expect(h.state.resumeStream).toHaveBeenCalledTimes(n); await reconnect({ status: 204, ok: false }); } expect(screen.queryByText(/reconnecting/i)).toBeNull(); const retry = screen.getByText("Retry"); act(() => { fireEvent.click(retry); }); expect(h.state.resumeStream).toHaveBeenCalledTimes(6); expect(screen.getByText(/reconnecting/i)).toBeTruthy(); }); it("#488 commit 3: two breaks in a row produce two reconnect cycles", async () => { renderLive(); // First break -> reconnect -> re-attach live. disconnect(); advanceToAttempt(1); expect(h.state.resumeStream).toHaveBeenCalledTimes(1); await reconnect({ status: 200, ok: true }); expect(screen.queryByText(/reconnecting/i)).toBeNull(); // The re-attached observer stream drops AGAIN -> a SECOND reconnect cycle // (the old one-shot !wasResumed gate sent this to silent poll). disconnect(); expect(screen.getByText(/reconnecting/i)).toBeTruthy(); advanceToAttempt(1); expect(h.state.resumeStream).toHaveBeenCalledTimes(2); }); it("does NOT reconnect when autonomous runs are disabled", () => { renderThread({ autonomousRunsEnabled: false, initialRows: settledTail() }); disconnect(); expect(screen.queryByText(/reconnecting/i)).toBeNull(); expect( screen.getByText("Connection lost — the answer was interrupted."), ).toBeTruthy(); advanceToAttempt(1); expect(h.state.resumeStream).not.toHaveBeenCalled(); }); it("#488 commit 4a: the poll idle cap surfaces a stalled banner + Retry (not silent)", async () => { renderLive(); disconnect(); advanceToAttempt(1); await reconnect({ status: 204, ok: false }); // arms the poll (reconnecting) // No activity for the whole idle cap -> stalled. act(() => { vi.advanceTimersByTime(10 * 60_000); }); expect(screen.getByText(/the run stopped responding/i)).toBeTruthy(); expect(screen.getByText("Retry")).toBeTruthy(); }); });