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:
@@ -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<string, unknown>) => 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user