Stage 382: PR #2499
This commit is contained in:
1 parent
b170980522
commit
f731f1fa43
3 files changed
+79
-9
No files matched your search
@@ -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
@@ -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});
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user