diff --git a/static/messages.js b/static/messages.js index 4bccc85b..57133a01 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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(); diff --git a/tests/test_1694_terminal_cleanup_ownership.py b/tests/test_1694_terminal_cleanup_ownership.py index 57fea1d1..f2a12c53 100644 --- a/tests/test_1694_terminal_cleanup_ownership.py +++ b/tests/test_1694_terminal_cleanup_ownership.py @@ -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" + )