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:
1 parent
03013606fe
commit
4b791bd820
2 files changed
+71
-2
No files matched your search
+14
-1
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user