fix(client): фаза FSM решает баннер — reconnect не маскируется остаточным error (#488)

Браузерный QA: маршрутизация disconnect-first работает (лесенка реально стартует),
но пользователь всё равно видел терминальный «Lost connection… reload», а не
«reconnecting… (N/5)».

Трасса (подтверждена по коду): рендер `{errorView ? <ChatErrorAlert/> : 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 09:55:39 +03:00
parent c7a38e274e
commit 803bbd40b4
2 changed files with 48 additions and 9 deletions
@@ -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 ? (
<ChatErrorAlert title={errorView.title} detail={errorView.detail} mb="xs" />
) : phase.name === "reconnecting" ? (
// #430/#488: while auto-reconnecting to a detached run's live tail, show