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)
This commit is contained in:
webtecnica committed 2026-09-23 09:29:00 -03:00
1 parent 03013606fe
commit 4b791bd820
2 files changed
+71 -2

No files matched your search

+14 -1
View File
@@ -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;
+57 -1
View File
@@ -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);