From e62338d3a0756086845fef73b0e73efd9797788b Mon Sep 17 00:00:00 2001 From: Basit Mustafa Date: Fri, 24 Apr 2026 12:33:56 -0700 Subject: [PATCH] fix(queue): drain correct session queue after cross-session stream completion (#964) When a session finishes streaming while the user has switched to a different session, setBusy(false) was draining S.session.session_id (the currently *viewed* session) instead of the session that actually finished. Queued follow-up messages were silently dropped. Root cause: setBusy() has no context about which session triggered it. The activeSid closure variable inside attachLiveStream() knew the right session but was not propagated. Fix: add _queueDrainSid module global (null by default). Stream done and error handlers set it to activeSid immediately before calling setBusy(false). setBusy(false) reads and clears _queueDrainSid, falling back to S.session if it is unset (the common case where the user hasn't switched away). Handlers patched: done event, start-call error handler, stream_end/stream_stop reconnection fallback, and max-retry error exit. Co-authored with Claude Sonnet 4.6 / Anthropic. --- static/messages.js | 8 ++++---- static/ui.js | 15 ++++++++++++--- tests/test_regressions.py | 5 +++-- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/static/messages.js b/static/messages.js index 4f62c5f2..8b81e5f1 100644 --- a/static/messages.js +++ b/static/messages.js @@ -153,7 +153,7 @@ async function send(){ if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);removeThinking(); if(!_clarifySessionId || _clarifySessionId===activeSid) hideClarifyCard(true); S.messages.push({role:'assistant',content:`**Error:** ${errMsg}`}); - renderMessages();setBusy(false);setComposerStatus(`Error: ${errMsg}`); + _queueDrainSid=activeSid;renderMessages();setBusy(false);setComposerStatus(`Error: ${errMsg}`); return; } @@ -726,7 +726,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _markSessionViewed(activeSid, d.session.message_count ?? S.messages.length); syncTopbar();renderMessages();loadDir('.'); } - renderSessionList();setBusy(false);setStatus(''); + _queueDrainSid=activeSid;renderSessionList();setBusy(false);setStatus(''); setComposerStatus(''); playNotificationSound(); sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished'); @@ -900,7 +900,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _markSessionViewed(activeSid, session.message_count ?? S.messages.length); syncTopbar();renderMessages(); } - renderSessionList();setBusy(false);setComposerStatus(''); + _queueDrainSid=activeSid;renderSessionList();setBusy(false);setComposerStatus(''); return true; }catch(_){ return false; @@ -951,7 +951,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none'; clearLiveToolCards(); removeThinking(); - setBusy(false); + _queueDrainSid=activeSid;setBusy(false); setComposerStatus(''); renderMessages(); renderSessionList(); diff --git a/static/ui.js b/static/ui.js index e6d85562..00cf1261 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1,6 +1,13 @@ const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default'}; const INFLIGHT={}; // keyed by session_id while request in-flight const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns +// Tracks which session's queue to drain in setBusy(false). +// Set to activeSid just before setBusy(false) in done/error handlers so the +// queue drains the session that *finished*, not the one currently viewed. +// Single-shot: setBusy() reads and clears this on every call. Concurrent +// back-to-back stream completions would overwrite it, but HTTPServer is +// single-threaded so only one done event fires at a time in practice. +let _queueDrainSid=null; const $=id=>document.getElementById(id); function _getSessionQueue(sid, create=false){ if(!sid) return []; @@ -946,10 +953,12 @@ function setBusy(v){ setComposerStatus(''); // Always hide Cancel button when not busy const _cb=$('btnCancel');if(_cb)_cb.style.display='none'; - const sid=S.session&&S.session.session_id; + const sid=_queueDrainSid||(S.session&&S.session.session_id); + _queueDrainSid=null; updateQueueBadge(sid); - // Drain one queued message for the currently viewed session after UI settles - const next=sid?shiftQueuedSessionMessage(sid):null; + // Drain one queued message for the finished session after UI settles + const _isViewedSid=!S.session||sid===S.session.session_id; + const next=sid&&_isViewedSid?shiftQueuedSessionMessage(sid):null; if(next){ updateQueueBadge(sid); setTimeout(()=>{ diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 66a9c2ac..ea99ff27 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -501,8 +501,9 @@ def test_session_scoped_message_queue_frontend_wiring(cleanup_test_sessions): assert "const SESSION_QUEUES" in ui_src assert "function queueSessionMessage" in ui_src assert "function shiftQueuedSessionMessage" in ui_src - assert "const sid=S.session&&S.session.session_id;" in ui_src - assert "const next=sid?shiftQueuedSessionMessage(sid):null;" in ui_src + # _queueDrainSid tracks which session's queue to drain even after session switches + assert "_queueDrainSid" in ui_src + assert "shiftQueuedSessionMessage(sid)" in ui_src assert "queueSessionMessage(S.session.session_id" in messages_src assert "updateQueueBadge(S.session.session_id);" in messages_src assert "updateQueueBadge(sid);" in sessions_src