Stage 382: PR #2499

This commit is contained in:
nesquena-hermes committed 2026-05-18 03:43:01 +00:00
1 parent b170980522
commit f731f1fa43
3 files changed
+79 -9

No files matched your search

+4
View File
@@ -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
+12 -6
View File
@@ -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});
+63 -3
View File
@@ -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