fix(client): обрыв SSE до первого кадра ассистента → ран подхватывается (#488)
Коммит 2. Раньше вход в reconnect требовал `message?.role === "assistant"`, и обрыв в setup-фазе (до первого кадра ассистента, включая сборку MCP-тулсета с дедлайном до 60 c) не давал НИ реконнекта, НИ поллинга — а detached-ран продолжал писать в страницы (тихая дыра целостности). Правка: вход в reconnect по РАН-ФАКТУ (активный detached-ран), а не по наличию assistant-сообщения. В autonomous-режиме ран активен весь ход, поэтому здесь сигнал run-факта — сам autonomousRunsEnabled; более богатый серверный run-факт (POST /run / runId из start-метаданных) смоделирован и покрыт тестами в FSM (run-fsm.ts FINISH_DISCONNECT по ctx.runFact) и приезжает с полной миграцией компонента. При отсутствии assistant-строки reconnect идёт БЕЗ strip/anchor (простой live-attach — на экране нечего перестраивать). Тест: «обрыв до первого кадра → баннер reconnect + resumeStream + attach без anchor», плюс FSM-переход (уже зелёный в коммите 1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1122,6 +1122,35 @@ describe("ChatThread — live reconnect after isDisconnect (#430)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// #488 commit 2: a break during the SETUP phase — before the first assistant
|
||||
// frame (onFinish carries no assistant message) — must STILL reconnect, because
|
||||
// a detached autonomous run keeps writing to pages. The old gate required
|
||||
// `message?.role === "assistant"`, so this fell through to neither reconnect nor
|
||||
// poll. With no anchor row, the attach is a PLAIN live attach (no strip/anchor).
|
||||
it("#488 commit 2: disconnect BEFORE the first assistant frame reconnects with no anchor", () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: undefined,
|
||||
isAbort: false,
|
||||
isDisconnect: true,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
// The reconnect banner shows (not the dead terminal "connection lost" notice).
|
||||
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText("Connection lost — the answer was interrupted."),
|
||||
).toBeNull();
|
||||
advanceToAttempt(1);
|
||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||
// No anchor -> a plain attach URL (no expect=live&anchor pinning a row).
|
||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||
"/api/ai-chat/runs/c1/stream",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips the pinned live row before replay so content is NOT duplicated", () => {
|
||||
renderLiveThenDisconnect();
|
||||
advanceToAttempt(1);
|
||||
|
||||
@@ -594,30 +594,49 @@ export default function ChatThread({
|
||||
);
|
||||
}
|
||||
}
|
||||
// (2b) #430: a LIVE (non-resumed) detached run whose SSE just dropped. The
|
||||
// server run keeps executing, so instead of a dead "Lost connection" banner
|
||||
// start a reconnect sequence: pin the CURRENT streaming assistant row as the
|
||||
// strip/anchor (the live tail is the already-shown partial in `messages`, not
|
||||
// a persistent row) and re-attach to the live tail via the resumable machinery.
|
||||
const startedReconnect =
|
||||
// (2b) #430/#488: a LIVE (non-resumed) detached run whose SSE just dropped.
|
||||
// The server run keeps executing, so instead of a dead "Lost connection"
|
||||
// banner start a reconnect sequence and re-attach to the live tail via the
|
||||
// resumable machinery.
|
||||
//
|
||||
// #488 commit 2 — enter reconnect by the RUN-FACT (an active detached run),
|
||||
// NOT by the presence of an assistant message. The old gate additionally
|
||||
// required `message?.role === "assistant"`, so a break during the SETUP phase
|
||||
// — before the first assistant frame, INCLUDING an up-to-60s MCP-toolset
|
||||
// assembly — fell through to neither reconnect NOR poll while the detached
|
||||
// run kept writing to pages (a silent data-integrity hole). In autonomous
|
||||
// mode a run is active for the whole turn, so `autonomousRunsEnabled` is the
|
||||
// run-fact signal here; the richer server run-fact (POST /run / start-metadata
|
||||
// runId) is modelled + tested in the FSM (run-fsm.ts `FINISH_DISCONNECT`,
|
||||
// gated on `ctx.runFact`) and lands with the full component migration.
|
||||
//
|
||||
// When there IS an assistant row, pin it as the strip/anchor so the live
|
||||
// replay rebuilds it without duplicating parts; when there is NONE (the
|
||||
// pre-first-frame break), reconnect WITHOUT an anchor (a plain live attach —
|
||||
// there is nothing on screen to rebuild).
|
||||
const canReconnect =
|
||||
isDisconnect &&
|
||||
!wasResumed &&
|
||||
autonomousRunsEnabled === true &&
|
||||
mountedRef.current &&
|
||||
message?.role === "assistant" &&
|
||||
typeof message.id === "string";
|
||||
if (startedReconnect) {
|
||||
beginReconnect({
|
||||
id: message.id,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
status: "streaming",
|
||||
createdAt: new Date().toISOString(),
|
||||
// Preserve the partial parts so a 204 restore (onNoActiveStream) re-shows
|
||||
// what was on screen while the degraded poll catches the run up to
|
||||
// terminal (rowToUiMessage prefers metadata.parts).
|
||||
metadata: { parts: message.parts },
|
||||
});
|
||||
mountedRef.current;
|
||||
if (canReconnect) {
|
||||
const hasAnchor =
|
||||
message?.role === "assistant" && typeof message.id === "string";
|
||||
beginReconnect(
|
||||
hasAnchor
|
||||
? {
|
||||
id: message.id,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
status: "streaming",
|
||||
createdAt: new Date().toISOString(),
|
||||
// Preserve the partial parts so a 204 restore (onNoActiveStream)
|
||||
// re-shows what was on screen while the degraded poll catches the
|
||||
// run up to terminal (rowToUiMessage prefers metadata.parts).
|
||||
metadata: { parts: message.parts },
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
// (3) Standard branches.
|
||||
// Forward the authoritative server chatId (streamed on the assistant
|
||||
@@ -632,7 +651,7 @@ export default function ChatThread({
|
||||
// "connection lost" notice — the reconnect banner takes over (#430).
|
||||
if (isError) setStopNotice(null);
|
||||
else if (isAbort) setStopNotice("manual");
|
||||
else if (isDisconnect) setStopNotice(startedReconnect ? null : "disconnect");
|
||||
else if (isDisconnect) setStopNotice(canReconnect ? null : "disconnect");
|
||||
else setStopNotice(null);
|
||||
// A resumed turn NEVER flushes the queue (invariant 7): skip BOTH the
|
||||
// flush-on-abort branch and the plain flush. The local streamer is the only
|
||||
@@ -790,13 +809,15 @@ export default function ChatThread({
|
||||
[clearReconnectTimer, setReconnectStatePair, attemptReconnectOnce],
|
||||
);
|
||||
|
||||
// Start a fresh reconnect sequence, pinning `anchorRow` (the live run's assistant
|
||||
// row) as the strip/anchor reused by every attempt.
|
||||
// Start a fresh reconnect sequence. `anchorRow` (the live run's assistant row) is
|
||||
// pinned as the strip/anchor reused by every attempt; `null` (a pre-first-frame
|
||||
// break, #488 commit 2) means there is nothing on screen to rebuild, so attach
|
||||
// WITHOUT a strip/anchor (a plain live attach to whatever the run is emitting).
|
||||
const beginReconnect = useCallback(
|
||||
(anchorRow: IAiChatMessageRow) => {
|
||||
(anchorRow: IAiChatMessageRow | null) => {
|
||||
if (!autonomousRunsEnabled || !mountedRef.current) return;
|
||||
strippedRowRef.current = anchorRow;
|
||||
stripRef.current = true;
|
||||
stripRef.current = anchorRow !== null;
|
||||
scheduleReconnectAttempt(1);
|
||||
},
|
||||
[autonomousRunsEnabled, scheduleReconnectAttempt],
|
||||
|
||||
Reference in New Issue
Block a user