From 2ef09b0282a5df607a261f6606c0faf9ccbf4fc6 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 2 Oct 2026 18:53:16 +0300 Subject: [PATCH] =?UTF-8?q?fix=20the=20duplicate-previous-assistant-messag?= =?UTF-8?q?e=20bug:=20turn-settled=20for=20our=20own=20turn=20flags=20the?= =?UTF-8?q?=20ready=20flip=20to=20canonize=20onto=20the=20record=20(push-p?= =?UTF-8?q?ure=20fix=20B=20=E2=80=94=20the=20tail's=20c=5F-id=20streamed?= =?UTF-8?q?=20copies=20were=20never=20absorbed,=20so=20the=20next=20send's?= =?UTF-8?q?=20adoption=20window=20rendered=20them=20twice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/chat.tsx | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index f771b8a..2b966f0 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -639,9 +639,26 @@ export function Chat({ // The coverage ring re-reads at turn settle too (extraction-UX // redesign, 2026-09-24); the ring's own running-poll handles runs. setCoverageTick((t) => t + 1); + // CANONIZATION (fix B, the duplicate-previous-message bug, 2026-10-02): + // turn-settled for OUR OWN turn arrives while the stream is still open + // (endTurn emits from onFinish before the response terminates, so the + // SSE frame reliably beats [DONE]) — the handler below cannot sync + // there, it flags instead, and THIS flip consumes the flag: the record + // is guaranteed durable (endTurn fires only after persistTurn, the S2 + // ordering law), so replacing the view onto it absorbs the tail's + // streamed c_-id copies into base under their server ids. Without + // this the tail kept those copies forever and the next send's + // steer-adoption window rendered them a second time. The wake handoff + // cannot be missed by this sync: the wake fires only after the idle + // gates + grace (≥750ms past this flip), so its turn-started event + // lands at an idle, already-canonical view and attaches normally. + if (settleAnnouncedRef.current) { + settleAnnouncedRef.current = false; + void syncFromServer(); + } // (The extraction-discovery nudges that lived here died at S5: the // pipeline announces every phase on the events channel.) - }, [status, syncTurns]); + }, [status, syncTurns, syncFromServer]); // A stream that died MID-FLIGHT (network): the runner decoupled the turn // from its view, so the server turn runs ON behind the error banner — @@ -785,6 +802,11 @@ export function Chat({ // between renders, and a ref holds the live value at event time. const statusRef = useRef(status); statusRef.current = status; + /** Fix B (2026-10-02): turn-settled arrived for OUR OWN turn — the + * record is durable (endTurn fires only after persistTurn); the ready + * flip consumes this flag to canonize the view onto the record. See the + * settle effect and the turn-settled handler below. */ + const settleAnnouncedRef = useRef(false); useServerEvent("turn-started", (p) => { // Known non-issue: beginTurn emits before registerTurn in the same // synchronous request flow; a GET /stream inside that sub-ms window @@ -797,7 +819,17 @@ export function Chat({ }); useServerEvent("turn-settled", (p) => { if (p.chatId !== chatId) return; - if (localStreamRef.current) return; // our own [DONE] path owns the settle + if (localStreamRef.current) { + // Our own [DONE] path owns the BUSY settle — but not a no-op (fix B, + // 2026-10-02): the event is the record-durable guarantee (endTurn + // fires only after persistTurn), and it reliably beats [DONE] here + // (endTurn emits from onFinish before the response terminates). Flag + // it; the ready flip consumes the flag and canonizes the view onto + // the record. (If [DONE] somehow won this race instead, this branch + // didn't run and the fully-idle branch below already synced.) + settleAnnouncedRef.current = true; + return; + } if (turnInFlightRef.current) { // The attached stream's terminal frame owns the normal settle — // syncing HERE would swap a stale snapshot mid-stream (the