diff --git a/static/sessions.js b/static/sessions.js index 3b29a96e..c1a1cc70 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -612,9 +612,22 @@ function _syncSessionListSnapshotOnVisit(sid, messageCount, lastMessageAt) { const count = Number(messageCount || 0); const last = Number(lastMessageAt || 0); _sessionListSnapshotById.set(sid, {message_count: count, last_message_at: last}); + // #5917 gate finding: derive the visited session's streaming state from its + // OWN (target-owned) metadata, NOT the global S.busy / S.activeStreamId + // flags. When switching from a BUSY session A to an IDLE session B, those + // globals can still belong to A at this point in the load, so reading them + // here would wrongly record idle B as streaming — a later hidden-tab poll + // would then see a streaming->stopped transition and manufacture a phantom + // unread completion for B. Only the session object's own is_streaming / + // active_stream_id / pending-message fields describe THIS session. + const target = (S.session && S.session.session_id === sid) ? S.session : null; const isStreaming = Boolean( - S.session && S.session.session_id === sid - && (S.busy || S.activeStreamId || (S.session.active_stream_id && S.session.pending_user_message)) + target && ( + target.is_streaming || + target.active_stream_id || + target.pending_user_message || + target.has_pending_user_message + ) ); _sessionStreamingById.set(sid, isStreaming); if (!isStreaming) _forgetObservedStreamingSession(sid); @@ -2926,7 +2939,15 @@ async function _ensureMessagesLoaded(sid, opts) { if(typeof scheduleTodosRefresh === 'function'){ scheduleTodosRefresh(); } - _setSessionViewedCount(sid, Number(S.session.message_count || msgs.length)); + // Only sync the viewed count (which also clears any completion-unread + // marker via _setSessionViewedCount -> _clearSessionCompletionUnread) + // when the session is STILL actively viewed. A hidden-tab completion that + // lands during the awaited message fetch above must NOT be silently marked + // read here — mirror the same _isSessionActivelyViewedForList(sid) guard + // used on the post-load re-ack in loadSession(). (#5917 gate finding) + if(typeof _isSessionActivelyViewedForList !== 'function' || _isSessionActivelyViewedForList(sid)){ + _setSessionViewedCount(sid, Number(S.session.message_count || msgs.length)); + } if(typeof syncTopbar==='function') syncTopbar(); } } diff --git a/tests/test_session_unread_dot_on_visit.py b/tests/test_session_unread_dot_on_visit.py index a966ded2..bd6f5dbd 100644 --- a/tests/test_session_unread_dot_on_visit.py +++ b/tests/test_session_unread_dot_on_visit.py @@ -297,3 +297,150 @@ console.log(JSON.stringify({{flagged}})); "a deferred list poll after a visit must not re-flag the open, unchanged " "session as unread — the visit-ack synced snapshot + viewed count" ) + + +# ── Functional: hidden-tab completion during message load stays unread ─────── + +def _extract_async(name: str) -> str: + """Like _extract, but preserve an `async` prefix so `await` bodies stay valid.""" + body = _extract(name) + idx = SESSIONS_JS.rindex("async ", 0, SESSIONS_JS.index(body)) + # Only treat it as async if the `async ` keyword immediately precedes it. + if SESSIONS_JS[idx:idx + len("async ")] == "async " and \ + SESSIONS_JS[idx:].startswith("async function " + name): + return "async " + body + return body + + +def _hidden_completion_script(*, hidden: bool) -> str: + """Build a Node harness that drives the REAL _ensureMessagesLoaded() through a + delayed messages fetch, marks a completion mid-fetch, and reports whether the + completion-unread marker survives.""" + ensure = _extract_async("_ensureMessagesLoaded") + set_viewed = _extract("_setSessionViewedCount") + clear_unread = _extract("_clearSessionCompletionUnread") + get_unread = _extract("_getSessionCompletionUnread") + save_unread = _extract("_saveSessionCompletionUnread") + mark_unread = _extract("_markSessionCompletionUnread") + get_counts = _extract("_getSessionViewedCounts") + save_counts = _extract("_saveSessionViewedCounts") + actively_viewed = _extract("_isSessionActivelyViewedForList") + + visibility = "'hidden'" if hidden else "'visible'" + has_focus = "false" if hidden else "true" + + return f""" +// Minimal localStorage shim. +const _store = {{}}; +const localStorage = {{ + getItem: (k) => (k in _store ? _store[k] : null), + setItem: (k, v) => {{ _store[k] = String(v); }}, +}}; +const SESSION_VIEWED_COUNTS_KEY = 'v'; +const SESSION_COMPLETION_UNREAD_KEY = 'u'; +let _sessionViewedCounts = null; +let _sessionCompletionUnread = null; +let _messagesTruncated = false; +let _oldestIdx = 0; +let _messageRenderWindowSize = 0; +let _pendingCarryForwardSnapshot = null; +let _loadingSessionId = 'open'; +let _loadSessionGeneration = 0; +const window = {{}}; +// Tab starts VISIBLE+FOCUSED: the load begins while the user is watching. +let _visibility = 'visible'; +let _focused = true; +const document = {{ + get visibilityState() {{ return _visibility; }}, + hasFocus: () => _focused, +}}; +let S = {{ session: {{ session_id: 'open', message_count: 0 }}, messages: [], lastUsage: {{}} }}; + +// Stubs for the incidental side effects _ensureMessagesLoaded touches. +function _clearSameSessionForceReloadHint() {{}} +function _messageReloadLimitForSession() {{ return 0; }} +function _syncToolCallsForLoadedMessages() {{}} +function clearLiveToolCards() {{}} + +// Delayed messages fetch: resolves only when we release it, simulating a slow +// /api/session?messages=1 response that spans the tab going hidden. +let _releaseApi; +const _apiResult = new Promise((res) => {{ _releaseApi = res; }}); +let _apiCalled = false; +async function api(url) {{ _apiCalled = true; return _apiResult; }} + +{get_counts} +{save_counts} +{get_unread} +{save_unread} +{clear_unread} +{mark_unread} +{set_viewed} +{actively_viewed} +{ensure} + +function _hasMarker() {{ + return Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), 'open'); +}} + +(async () => {{ + const p = _ensureMessagesLoaded('open', {{}}); + // Let the synchronous body run up to `await api(...)`. + await Promise.resolve(); + await Promise.resolve(); + const apiIssued = _apiCalled; + // Mid-fetch: the tab's visibility/focus flips to the test scenario and a + // background completion lands, marking the session unread. + _visibility = {visibility}; + _focused = {has_focus}; + _markSessionCompletionUnread('open', 6); + const markerBefore = _hasMarker(); + // The delayed messages response finally arrives and the load finishes. + _releaseApi({{ session: {{ session_id: 'open', message_count: 6, messages: [{{ role: 'assistant', content: 'x' }}] }} }}); + await p; + const markerAfter = _hasMarker(); + const viewed = _getSessionViewedCounts()['open']; + console.log(JSON.stringify({{ + apiIssued, + markerBefore, + markerAfter, + viewed: viewed === undefined ? null : viewed, + }})); +}})(); +""" + + +def test_hidden_tab_completion_during_message_load_survives(): + """#5917 gate finding (SILENT): a completion that lands while the tab is + hidden DURING the awaited message fetch inside _ensureMessagesLoaded() must + NOT be silently marked read. The guarded viewed-count clear must skip when + the session is no longer actively viewed.""" + out = _run_node(_hidden_completion_script(hidden=True)) + assert out["apiIssued"] is True, "precondition: the delayed messages fetch was issued" + assert out["markerBefore"] is True, "precondition: the mid-fetch completion marked the session unread" + assert out["markerAfter"] is True, ( + "a hidden-tab completion landing during the awaited message fetch must " + "SURVIVE — _ensureMessagesLoaded() must not clear the unread marker via " + "an unconditional _setSessionViewedCount when the tab is not actively viewing" + ) + assert out["viewed"] is None, ( + "the viewed count must NOT be synced for a hidden/background session, " + "otherwise the completion-unread marker would be cleared" + ) + + +def test_visible_tab_message_load_still_syncs_viewed_count(): + """Control: the guard must not over-block. An ACTIVELY-viewed session still + updates its viewed count normally through the message-load path (clearing any + marker), so the fix is scoped to hidden/background sessions only.""" + out = _run_node(_hidden_completion_script(hidden=False)) + assert out["apiIssued"] is True + assert out["markerBefore"] is True + assert out["markerAfter"] is False, ( + "an actively-viewed session must still clear its completion-unread marker " + "when the message load finishes" + ) + assert out["viewed"] == 6, ( + "an actively-viewed session must still sync its viewed count to the " + "loaded message count" + )