From f731f1fa4374d558efbbec73032e1989455cd317 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 18 May 2026 03:43:01 +0000 Subject: [PATCH] Stage 382: PR #2499 --- CHANGELOG.md | 4 ++ static/sessions.js | 18 +++-- .../test_issue2454_active_session_spinner.py | 66 ++++++++++++++++++- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0b7ec2..862b16a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Keep server-idle session rows from inheriting stale local streaming fields during sidebar optimistic merging, so PWA/browser caches cannot keep a completed session's spinner alive after `/api/sessions` reports no active stream or pending user message. + ## [v0.51.88] — 2026-05-18 — Release BL (stage-381 — 3-PR security + UX + lineage batch — session-bound CSRF tokens for unsafe browser requests + quoted-reply selected-text composer append + compression-continuation sidebar collapse) ### Security diff --git a/static/sessions.js b/static/sessions.js index 354551da..6d333ee4 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -256,6 +256,10 @@ function _isSessionEffectivelyStreaming(s) { return Boolean(s && (s.is_streaming || _isSessionLocallyStreaming(s))); } +function _isServerIdleSessionRow(s) { + return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message); +} + function _reconcileActiveSessionIdleStateFromList(serverRows) { if (!S || !S.session || !S.session.session_id) return false; if (typeof _sendInProgress !== 'undefined' && _sendInProgress) return false; @@ -263,8 +267,7 @@ function _reconcileActiveSessionIdleStateFromList(serverRows) { const sid=S.session.session_id; const serverRow=serverRows.find(s=>s&&s.session_id===sid); if (!serverRow) return false; - const serverRowIsIdle=!serverRow.is_streaming&&!serverRow.active_stream_id&&!serverRow.pending_user_message; - if (!serverRowIsIdle) return false; + if (!_isServerIdleSessionRow(serverRow)) return false; let changed=false; if (S.busy) { S.busy=false; changed=true; } if (S.activeStreamId) { S.activeStreamId=null; changed=true; } @@ -277,6 +280,8 @@ function _reconcileActiveSessionIdleStateFromList(serverRows) { S.session.active_stream_id=null; S.session.pending_user_message=null; } + _sessionStreamingById.set(sid, false); + _forgetObservedStreamingSession(sid); if (changed&&typeof updateSendBtn==='function') updateSendBtn(); return changed; } @@ -1865,6 +1870,7 @@ function _mergeOptimisticFirstTurnSessions(fetchedSessions){ const idx=bySid.has(sid)?bySid.get(sid):-1; if(idx>=0){ const fetched=merged[idx]||{}; + const fetchedIsServerIdle=_isServerIdleSessionRow(fetched); const localCount=Number(local.message_count||0); const fetchedCount=Number(fetched.message_count||0); const localTs=Number(local.last_message_at||local.updated_at||0); @@ -1875,10 +1881,10 @@ function _mergeOptimisticFirstTurnSessions(fetchedSessions){ message_count:Math.max(localCount,fetchedCount), last_message_at:Math.max(localTs,fetchedTs), updated_at:Math.max(Number(local.updated_at||0),Number(fetched.updated_at||0),localTs,fetchedTs), - active_stream_id:fetched.active_stream_id||local.active_stream_id||null, - pending_user_message:fetched.pending_user_message||local.pending_user_message||null, - pending_started_at:fetched.pending_started_at||local.pending_started_at||null, - is_streaming:Boolean(fetched.is_streaming||local.is_streaming||_isSessionLocallyStreaming(local)), + active_stream_id:fetchedIsServerIdle?null:(fetched.active_stream_id||local.active_stream_id||null), + pending_user_message:fetchedIsServerIdle?null:(fetched.pending_user_message||local.pending_user_message||null), + pending_started_at:fetchedIsServerIdle?null:(fetched.pending_started_at||local.pending_started_at||null), + is_streaming:fetchedIsServerIdle?false:Boolean(fetched.is_streaming||local.is_streaming||_isSessionLocallyStreaming(local)), }; }else{ merged.push({...local,is_streaming:true}); diff --git a/tests/test_issue2454_active_session_spinner.py b/tests/test_issue2454_active_session_spinner.py index 7db2d390..22e7d59d 100644 --- a/tests/test_issue2454_active_session_spinner.py +++ b/tests/test_issue2454_active_session_spinner.py @@ -8,6 +8,8 @@ makes the sidebar row render as streaming even after the server says idle. """ from pathlib import Path +import json +import subprocess REPO = Path(__file__).resolve().parents[1] SESSIONS_SRC = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") @@ -31,18 +33,22 @@ def _function_body(src: str, signature: str) -> str: def test_active_session_idle_reconcile_clears_stale_busy_and_inflight_state(): + helper_body = _function_body(SESSIONS_SRC, "function _isServerIdleSessionRow(") body = _function_body(SESSIONS_SRC, "function _reconcileActiveSessionIdleStateFromList(") assert "serverRows" in body, "reconcile must inspect raw /api/sessions rows before optimistic merging" assert "S.session.session_id" in body, "reconcile must target the currently active session" assert "_sendInProgress" in body, "cleanup must not interrupt a send that has not received stream_id yet" - assert "!serverRow.is_streaming" in body, "server idle metadata must gate the cleanup" - assert "!serverRow.active_stream_id" in body, "active stream id must be absent before cleanup" - assert "!serverRow.pending_user_message" in body, "pending user text must be absent before cleanup" + assert "_isServerIdleSessionRow(serverRow)" in body, "server idle metadata must gate the cleanup" + assert "!s.is_streaming" in helper_body, "server idle helper must require is_streaming=false" + assert "!s.active_stream_id" in helper_body, "server idle helper must require no active stream id" + assert "!s.pending_user_message" in helper_body, "server idle helper must require no pending user text" assert "S.busy=false" in body, "stale local busy state must be cleared" assert "S.activeStreamId=null" in body, "stale active stream id must be cleared" assert "delete INFLIGHT[sid]" in body, "stale active-session inflight cache must be purged" assert "clearInflightState(sid)" in body, "persisted inflight cache must be cleared too" + assert "_sessionStreamingById.set(sid, false)" in body, "observed active streaming state must be reset" + assert "_forgetObservedStreamingSession(sid)" in body, "persisted observed streaming marker must be cleared" assert "updateSendBtn()" in body, "composer controls must reflect the idle state after cleanup" @@ -60,3 +66,57 @@ def test_session_list_payload_reconciles_active_idle_state_before_optimistic_mer "local S.busy/INFLIGHT state must be reconciled against raw server rows " "before optimistic merging can re-label a stale active session as streaming" ) + + +def test_optimistic_merge_does_not_resurrect_server_idle_session_from_stale_cache(): + helper_body = _function_body(SESSIONS_SRC, "function _isServerIdleSessionRow(") + local_body = _function_body(SESSIONS_SRC, "function _isSessionLocallyStreaming(") + optimistic_body = _function_body(SESSIONS_SRC, "function _isOptimisticFirstTurnSessionRow(") + merge_body = _function_body(SESSIONS_SRC, "function _mergeOptimisticFirstTurnSessions(") + + assert "fetchedIsServerIdle" in merge_body, "merge must detect server-idle fetched rows" + assert "active_stream_id:fetchedIsServerIdle?null" in merge_body + assert "pending_user_message:fetchedIsServerIdle?null" in merge_body + assert "pending_started_at:fetchedIsServerIdle?null" in merge_body + assert "is_streaming:fetchedIsServerIdle?false" in merge_body + + script = f""" +let S = {{ session: null, busy: false }}; +let _sessionStreamingById = new Map(); +let _allSessions = [{{ + session_id: 'dogfood-session', + title: 'Dogfood Docs Routing Review', + message_count: 50, + last_message_at: 1000, + updated_at: 1000, + active_stream_id: 'stale-stream', + pending_user_message: 'stale pending text', + pending_started_at: 900, + is_streaming: true, +}}]; +function _isServerIdleSessionRow(s) {{{helper_body}}} +function _isSessionLocallyStreaming(s) {{{local_body}}} +function _isOptimisticFirstTurnSessionRow(s) {{{optimistic_body}}} +function _mergeOptimisticFirstTurnSessions(fetchedSessions) {{{merge_body}}} +const merged = _mergeOptimisticFirstTurnSessions([{{ + session_id: 'dogfood-session', + title: 'Dogfood Docs Routing Review', + message_count: 50, + last_message_at: 1100, + updated_at: 1100, + active_stream_id: null, + pending_user_message: null, + pending_started_at: null, + is_streaming: false, +}}]); +console.log(JSON.stringify(merged[0])); +""" + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + row = json.loads(result.stdout) + + assert row["session_id"] == "dogfood-session" + assert row["message_count"] == 50 + assert row["active_stream_id"] is None + assert row["pending_user_message"] is None + assert row["pending_started_at"] is None + assert row["is_streaming"] is False