From 4b791bd82099bca0bddc29004ef519ae0c1a7ca1 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 23 Sep 2026 09:29:00 -0300 Subject: [PATCH] fix(stream): require a current live assistant row to seed the replay floor The replay-authority predicate accepted any non-empty assistant message, but a new turn copies the full transcript into INFLIGHT.messages. In an established conversation that content is the previous turn's reply, so `canSeed` came back true with both stale cursor halves preserved and the server kept skipping the range that rebuilds the body. Message-based evidence now counts only from a current `_live` assistant positioned after the latest user boundary; a historical assistant row fails closed to floor 0 and an empty event id. Refs #7640 (blocker on #7651) --- static/sessions.js | 15 +++++++- tests/test_inflight_stream_reuse.py | 58 ++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 73a5bff1..107767b8 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1053,8 +1053,21 @@ function _inflightCanSeedJournalReplay(inflight){ const anchorScene=inflight.anchorActivityScene; if(anchorScene&&Array.isArray(anchorScene.activity_rows)&&anchorScene.activity_rows.length) return true; if(Array.isArray(inflight.messages)){ - return inflight.messages.some((msg)=>{ + // The transcript copy carries every earlier turn (#7651): in an established + // conversation the last assistant row is the PREVIOUS turn's reply, not the + // one the cursor belongs to, so it must never authorize a nonzero floor. + // Message evidence counts only from a current `_live` assistant standing + // after the latest user boundary; anything historical fails closed to zero. + const list=inflight.messages; + let latestUserIdx=-1; + for(let i=list.length-1;i>=0;i--){ + const row=list[i]; + if(row&&row.role==='user'){latestUserIdx=i;break;} + } + return list.some((msg,idx)=>{ if(!msg||msg.role!=='assistant') return false; + if(!msg._live) return false; + if(idx<=latestUserIdx) return false; const content=msg.content; if(typeof content==='string') return Boolean(content.trim()); if(Array.isArray(content)) return content.length>0; diff --git a/tests/test_inflight_stream_reuse.py b/tests/test_inflight_stream_reuse.py index d66d4a40..7f21b434 100644 --- a/tests/test_inflight_stream_reuse.py +++ b/tests/test_inflight_stream_reuse.py @@ -1113,7 +1113,7 @@ const recovering=[ {toolCalls:[{name:'read_file'}]}, {activityBurstAnchors:[{seq:12}]}, {anchorActivityScene:{activity_rows:[{kind:'tool'}]}}, - {messages:[{role:'user', content:'p'}, {role:'assistant', content:'answer'}]}, + {messages:[{role:'user', content:'p'}, {role:'assistant', content:'answer', _live:true}]}, ]; for (const extra of recovering) { const state={messages:[{role:'user', content:'p'}], ...extra}; @@ -1127,6 +1127,39 @@ for (const extra of recovering) { assert.strictEqual(_runJournalReplayEventIdForInflight(state), 'stream-e75e:206'); } +// #7651: an ESTABLISHED conversation copies the whole transcript, so the last +// assistant row is the previous turn's reply. Historical assistant content must +// never authorize the stale cursor that belongs to the still-running turn. +const established={ + messages:[ + {role:'user', content:'first question'}, + {role:'assistant', content:'the previous turn answer'}, + {role:'user', content:'current question'}, + ], + lastRunJournalSeq:206, + lastRunJournalEventId:'stream-e75e:206', +}; +assert.strictEqual( + _inflightCanSeedJournalReplay(established), + false, + 'a historical assistant row must not seed the replay floor' +); +assert.strictEqual(_runJournalReplayFloorForInflight(established), 0); +assert.strictEqual(_runJournalReplayEventIdForInflight(established), ''); + +// Even after the latest user boundary, evidence must be LIVE output: a +// settled/non-live assistant row at the current position still yields zero. +assert.strictEqual( + _runJournalReplayFloorForInflight({ + messages:[ + {role:'user', content:'current question'}, + {role:'assistant', content:'settled reply without _live'}, + ], + lastRunJournalSeq:206, + }), + 0 +); + // Degenerate inputs must stay on the zero floor. assert.strictEqual(_runJournalReplayFloorForInflight(null), 0); assert.strictEqual(_runJournalReplayFloorForInflight({}), 0); @@ -1193,6 +1226,29 @@ _normalizeInflightReplayCursorForReattach(recovering); assert.strictEqual(recovering.lastRunJournalSeq, 206); assert.strictEqual(recovering.lastRunJournalEventId, 'stream-e75e:206'); +// #7651: a historical assistant row must not protect the cursor either, or an +// established conversation settles with a blank body over a painted footer. +const established={ + streamId:'stream-e75e', + reattach:true, + messages:[ + {role:'user', content:'first question'}, + {role:'assistant', content:'the previous turn answer'}, + {role:'user', content:'current question'}, + ], + lastRunJournalSeq:206, + lastRunJournalEventId:'stream-e75e:206', +}; +_normalizeInflightReplayCursorForReattach(established); +assert.strictEqual(established.lastRunJournalSeq, 0); +assert.strictEqual(established.lastRunJournalEventId, ''); +assert.strictEqual(_runJournalReplayFloorForInflight(established), 0); +assert.strictEqual( + established.messages.length, + 3, + 'the transcript must survive; only the cursor halves are dropped' +); + const noCursor={streamId:'stream-e75e', messages:[{role:'user', content:'p'}]}; _normalizeInflightReplayCursorForReattach(noCursor); assert.strictEqual(noCursor.lastRunJournalSeq, undefined);