mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Stage 403: PR #2852 — fix(chat): settle stream_end without done by @ai-ag2026
This commit is contained in:
+27
-1
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user