Stage 403: PR #2852 — fix(chat): settle stream_end without done by @ai-ag2026

This commit is contained in:
nesquena-hermes
2026-05-24 17:10:01 +00:00
2 changed files with 57 additions and 1 deletions
+27 -1
View File
@@ -1682,6 +1682,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
});
source.addEventListener('done',e=>{
if(_streamFinalized) return;
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
const _doneData=JSON.parse(e.data);
@@ -1859,12 +1860,31 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_finishDone();
});
source.addEventListener('stream_end',e=>{
source.addEventListener('stream_end',async e=>{
if(_streamFinalized){
source.close();
return;
}
_terminalStateReached=true;
try{
const d=JSON.parse(e.data||'{}');
if((d.session_id||activeSid)!==activeSid) return;
}catch(_){}
// Some replay/journal paths can deliver stream_end without a preceding
// done event. In that case closing the EventSource is not enough: the
// live DOM/inflight state remains projected and can duplicate Thinking or
// assistant content until a later session switch. Settle from the persisted
// session before closing so the pane converges on canonical state.
if(await _restoreSettledSession()){
source.close();
return;
}
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
source.close();
});
@@ -2135,6 +2155,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const session=data&&data.session;
if(!session) return false;
if(session.active_stream_id||session.pending_user_message) return false;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_closeSource();
_clearApprovalForOwner();
@@ -91,3 +91,33 @@ def test_reconnect_settled_and_error_paths_keep_cleanup_session_scoped():
assert "stopApprovalPolling();stopClarifyPolling();" not in combined
assert "renderSessionList();setBusy(false)" not in combined
assert "_setActivePaneIdleIfOwner" in combined
def test_stream_end_without_done_restores_settled_session_before_closing():
"""If a journal/replay emits stream_end without done, the UI must settle from /api/session.
A close-only stream_end handler leaves live Thinking/inflight DOM around and
never replaces the pane with the persisted transcript when done is missing.
"""
body = _event_body("stream_end")
restore_idx = body.find("_restoreSettledSession()")
close_idx = body.rfind("source.close()")
finalized_idx = body.find("_streamFinalized=true")
assert restore_idx != -1, "stream_end handler must restore settled session when done is absent"
assert close_idx != -1, "stream_end handler must still close the EventSource"
assert restore_idx < close_idx, "restore must be attempted before closing the stream"
assert finalized_idx != -1, "stream_end terminal path must suppress trailing rAF/render work"
def test_done_handler_is_idempotent_for_replay_or_duplicate_done_events():
"""Duplicate/replayed done events must not replay completion sound or duplicate render."""
body = _event_body("done")
first_stmt = body.strip().splitlines()[0].strip()
assert "_streamFinalized" in first_stmt and "return" in first_stmt, (
"done handler must return early when the stream was already finalized"
)
guard_idx = body.find("if(_streamFinalized) return;")
sound_idx = body.find("playNotificationSound();")
assert sound_idx != -1, "done handler should still play completion sound once"
assert guard_idx != -1 and guard_idx < sound_idx, (
"completion sound must be behind the duplicate-done finalization guard"
)