From 803bbd40b4e09d5da39cf53f5e2c997e305a3cc1 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 09:55:39 +0300 Subject: [PATCH] =?UTF-8?q?fix(client):=20=D1=84=D0=B0=D0=B7=D0=B0=20FSM?= =?UTF-8?q?=20=D1=80=D0=B5=D1=88=D0=B0=D0=B5=D1=82=20=D0=B1=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=20=E2=80=94=20reconnect=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=81=D0=BA=D0=B8=D1=80=D1=83=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BE=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=BC=20error=20(#488)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Браузерный QA: маршрутизация disconnect-first работает (лесенка реально стартует), но пользователь всё равно видел терминальный «Lost connection… reload», а не «reconnecting… (N/5)». Трасса (подтверждена по коду): рендер `{errorView ? : phase === "reconnecting" ? ...}` даёт errorView ПРИОРИТЕТ над recovery-фазой. errorView = `error && describeChatError(...)`, где `error` — из useChat. Реальный ai@6 при isError выставляет useChat `error`, а дроп ВСЕГДА isError+isDisconnect → после починки маршрутизации FSM уходит в `reconnecting`, но `error` остаётся выставленным → errorView перекрывает reconnect-баннер. Тот же класс вакуум-мока, что и с isDisconnect, но на поле `error` (мок хардкодил error:null → в тестах маски не было). Фикс рендера: фаза FSM — источник истины. Терминальный errorView показывается ТОЛЬКО когда FSM реально терминален: `showError = errorView && phase === "error"`. В recovery-фазах (reconnecting/polling/stalled/superseding/stopping) выигрывает recovery-баннер (или контент стрима). Классифицированные 409 (#487) целы: supersede/ gate-409 ставят FSM в error(kind) → errorView показывается там, где должен. Мок useChat сделан реалистичным СИСТЕМНО: добавлено поле h.state.error, мок его возвращает; при любом isError-финише (дроп или провайдерская ошибка) тест ставит error в реальную форму, зеркаля связку SDK. MUTATION-VERIFY: откат рендер-гейта (errorView-first) → 8 reconnect/stalled-тестов краснеют (баннер маскируется); с гейтом — зелёные. Плюс отдельный тест «reconnect-баннер виден, не замаскирован». Всё прошлое цело: disconnect-first, epoch-штамп, honor-in-stopping, supersede- исходы, stalled/no-poll. Полный ai-chat 35 файлов / 378 / 0; tsc ai-chat 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/components/chat-thread.test.tsx | 43 +++++++++++++++---- .../ai-chat/components/chat-thread.tsx | 14 +++++- 2 files changed, 48 insertions(+), 9 deletions(-) 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 ee409e6a..8a507076 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 @@ -16,6 +16,10 @@ 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(), @@ -56,7 +60,7 @@ vi.mock("@ai-sdk/react", () => ({ sendMessage: h.state.sendMessage, status: h.state.status, stop: h.state.stop, - error: null, + error: h.state.error, setMessages: h.state.setMessages, resumeStream: h.state.resumeStream, }; @@ -156,6 +160,7 @@ function renderThread(props?: { 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; @@ -248,9 +253,10 @@ describe("ChatThread — send now", () => { 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 } (the server - // CAS closed it). The OLD overlap bug would route the LIVE new run into a false - // reconnect banner. + // 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" }] }, @@ -335,6 +341,7 @@ describe("ChatThread — send now", () => { // 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: [] }, @@ -413,6 +420,10 @@ describe("ChatThread — turn-end decision (onFinish)", () => { }) { 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: [] }, @@ -809,11 +820,13 @@ describe("ChatThread — live reconnect + stalled", () => { }); // A REAL live SSE drop. ai@6.0.207 emits BOTH { isError:true, isDisconnect:true } - // for a network TypeError — NOT the { isError:false } form the old tests fed. This - // is the form browser QA hit; with the buggy isError-first routing these tests go - // red (a real drop would surface the terminal error banner, not the reconnect - // ladder). MUTATION-VERIFY of the disconnect-first fix. + // 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, @@ -855,6 +868,20 @@ describe("ChatThread — live reconnect + stalled", () => { ); }); + 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) 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 3ec5930b..a82fbf77 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -978,6 +978,18 @@ export default function ChatThread({ // #487 409 codes, ...). Computed here so the SAME text can mirror into the export. const errorView = error ? describeChatError(error.message ?? "", t) : null; + // #488 (browser QA): the FSM PHASE is the source of truth for WHICH banner to + // show. A real network drop leaves useChat `error` SET even after the FSM has + // moved to `reconnecting` — in ai@6 a drop is always `{isError:true, + // isDisconnect:true}` and the SDK sets `error` alongside `isError`. So the + // terminal error banner must show ONLY when the FSM is actually TERMINAL + // (`error(kind)`); during recovery (reconnecting / polling / stalled / + // superseding / stopping) the recovery banner — or the streaming content — wins + // over the residual `error`, otherwise the terminal "Lost connection… reload" + // banner masks "reconnecting… (N/5)". This still surfaces the classified #487 409 + // errors: a supersede/gate 409 lands the FSM in `error(kind)`, so it shows then. + const showError = errorView !== null && phase.name === "error"; + // Role-picker empty state (unchanged from #149). const [rolePickedNoSend, setRolePickedNoSend] = useState(false); const handleRolePick = (role: IAiRole): void => { @@ -1011,7 +1023,7 @@ export default function ChatThread({ assistantName={assistantName} /> - {errorView ? ( + {showError && errorView ? ( ) : phase.name === "reconnecting" ? ( // #430/#488: while auto-reconnecting to a detached run's live tail, show