From de8f9c804ca4fa1a3e948ed67d749b4306311da3 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 03:24:19 +0300 Subject: [PATCH] =?UTF-8?q?fix(client):=20flushNext=20=D0=B2=20onFinish=20?= =?UTF-8?q?=D0=B3=D0=B5=D0=B9=D1=82=D0=B8=D1=82=D1=81=D1=8F=20mountedRef?= =?UTF-8?q?=20(#486)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Финальный onFinish->flushNext() не проверял live-mount флаг. Чистый onFinish может прийти ПОСЛЕ анмаунта треда (New-chat / переключение чата мид-стрим — асинхронные attach/resume оседают поздно): flush дергал очередь и re-POST'ил сообщение из брошенного треда — «призрачные» отправки/чаты-призраки. Остальные обращения к очереди уже гейтятся mountedRef; закрываем последнюю дыру. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/components/chat-thread.test.tsx | 46 +++++++++++++++++++ .../ai-chat/components/chat-thread.tsx | 8 +++- 2 files changed, 53 insertions(+), 1 deletion(-) 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