diff --git a/apps/client/src/features/ai-chat/components/chat-thread.test.tsx b/apps/client/src/features/ai-chat/components/chat-thread.test.tsx index 3d500495..0cebf8e6 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.test.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.test.tsx @@ -203,6 +203,52 @@ describe("ChatThread — send now (#198)", () => { }); }); +// #486: the final onFinish -> flushNext() must be gated on the live-mount flag. +// A clean onFinish can land AFTER the thread unmounts (New-chat / chat-switch +// mid-stream — the async attach/resume settles late); flushing then dequeues and +// re-POSTs a queued message from an abandoned thread (a "ghost" send). +describe("ChatThread — onFinish flush gated on mount (#486)", () => { + beforeEach(resetState); + afterEach(cleanup); + + it("a clean onFinish WHILE MOUNTED flushes the queued message (control)", () => { + renderThread(); + fireEvent.click(screen.getByTestId("queue-btn")); // enqueue "queued text" + expect(h.state.sendMessage).not.toHaveBeenCalled(); + + act(() => { + h.state.onFinish?.({ + message: { id: "a", role: "assistant", parts: [] }, + isAbort: false, + isDisconnect: false, + isError: false, + }); + }); + // Mounted: the queue flushes normally. + 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")); // enqueue "queued text" + h.state.sendMessage.mockClear(); + + // Chat switched away mid-stream: the streamer unmounts... + unmount(); + // ...and a late, clean onFinish lands on the abandoned thread. + act(() => { + h.state.onFinish?.({ + message: { id: "a", role: "assistant", parts: [] }, + isAbort: false, + isDisconnect: false, + isError: false, + }); + }); + // Gated on mountedRef: NOTHING is sent from the dead thread. + expect(h.state.sendMessage).not.toHaveBeenCalled(); + }); +}); + // #396: in autonomous mode a live sendNow must additionally request the // AUTHORITATIVE server stop of the detached run (a local abort is only a client // disconnect the server ignores) and arm a bounded 409 retry so the re-POST diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index 29f88dba..4c3662f7 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -659,7 +659,13 @@ export default function ChatThread({ return; } if (isAbort || isDisconnect || isError) return; - flushNext(); + // Gate the final flush on the live-mount flag (#486): a clean onFinish can + // land AFTER this thread unmounted (a New-chat / chat-switch mid-stream — + // the async attach/resume settles late). Flushing then dequeues and POSTs a + // queued message from an abandoned thread — a "ghost" send / ghost chat. + // Every other queue side effect already guards on mountedRef; this last one + // was the gap. + if (mountedRef.current) flushNext(); }, // `onError` runs in addition to `onFinish` (which ai@6 also calls on error). // Log the raw failure here for devtools; the UI shows a friendly classified