diff --git a/static/sessions.js b/static/sessions.js
index a4b0ba20..22e27cfb 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -19,6 +19,11 @@ const ICONS={
// responses from in-flight requests when the user switches sessions again
// before the first request completes (#1060).
let _loadingSessionId = null;
+// Each loadSession() invocation gets a monotonically increasing generation.
+// `_loadingSessionId` only tracks destination session_id, so same-session
+// concurrent loads can still race and overwrite each other unless we compare
+// the generation token as well.
+let _loadSessionGeneration = 0;
// #3306: Snapshot of S.messages captured by loadSession() right before it
// clears them on a force-reload of the active session. Consumed by
// _ensureMessagesLoaded() when calling _carryForwardEphemeralTurnFields so
@@ -1423,6 +1428,8 @@ async function loadSession(sid){
if(currentSid===sid && !forceReload && !_loadingSessionId) return;
// Mark this session as the in-flight load. Subsequent loadSession() calls
// will overwrite this; stale awaits use the mismatch to bail out (#1060).
+ const _loadGeneration = ++_loadSessionGeneration;
+ const _isCurrentLoad = () => _loadingSessionId === sid && _loadSessionGeneration === _loadGeneration;
_loadingSessionId = sid;
if(currentSid!==sid&&typeof _uploadPendingFilesSyncProgressForSession==='function')_uploadPendingFilesSyncProgressForSession(sid);
// Reset scroll state for fresh session navigation — the reader expects to
@@ -1453,7 +1460,7 @@ async function loadSession(sid){
// continuation can't wipe S.messages / write the loading placeholder /
// close streams for the session the user actually landed on (#1060 guard,
// extended to cover the new pre-switch await).
- if (_loadingSessionId !== sid) return;
+ if (!_isCurrentLoad()) return;
// Snapshot the live turn before msgInner is replaced. Preserves the activity
// timer, partial response, and tool cards so switching back does not rebuild
// the stream UI from scratch.
@@ -1532,7 +1539,7 @@ async function loadSession(sid){
} catch(e) {
const profileMismatch=_sessionProfileMismatchFromError(e);
if(profileMismatch && profileMismatch.profile && !opts.skipProfileResolve){
- if (_loadingSessionId !== sid) {
+ if (!_isCurrentLoad()) {
_rearmActiveSessionStream();
return;
}
@@ -1544,11 +1551,11 @@ async function loadSession(sid){
// navigated to a different session. If we no longer own the load, bail
// before clearing _loadingSessionId or retrying so the stale
// continuation can't hijack the UI back to the old target.
- if (_loadingSessionId !== sid) {
+ if (!_isCurrentLoad()) {
_rearmActiveSessionStream();
return;
}
- if (_loadingSessionId === sid) _loadingSessionId = null;
+ if (_isCurrentLoad()) _loadingSessionId = null;
return loadSession(sid,{...opts,skipProfileResolve:true,force:true});
}catch(switchErr){
e=switchErr;
@@ -1562,7 +1569,7 @@ async function loadSession(sid){
// for the session the user actually navigated to. If we no longer own the
// load, re-arm the active session's stream and bail before any DOM mutation
// or self-heal.
- if (_loadingSessionId !== sid) {
+ if (!_isCurrentLoad()) {
_rearmActiveSessionStream();
return;
}
@@ -1583,7 +1590,7 @@ async function loadSession(sid){
if(!currentSid || currentSid===sid){
try{ localStorage.removeItem('hermes-webui-session'); }catch(_){ }
try{ history.replaceState(null,'',_appRootPath()); }catch(_){ }
- if (_loadingSessionId === sid) _loadingSessionId = null;
+ if (_isCurrentLoad()) _loadingSessionId = null;
if(!currentSid){
throw e;
}
@@ -1607,7 +1614,7 @@ async function loadSession(sid){
// NOT restart — doing so would spin the SSE reconnect loop against a dead
// session_id.
const _selfHealedCurrent = (e.status===404) && (currentSid===sid);
- if (_loadingSessionId === sid) _loadingSessionId = null;
+ if (_isCurrentLoad()) _loadingSessionId = null;
// The session stream was stopped unconditionally at the top of this load
// (mirroring stopApprovalPolling). On the happy path it's restarted ~120
// lines below, but this failure exit never reaches that point — leaving
@@ -1636,14 +1643,14 @@ async function loadSession(sid){
// send users to empty state after re-login (#4028 follow-up).
if (!data) {
_clearSameSessionForceReloadHint(sid);
- if (_loadingSessionId === sid) _loadingSessionId = null;
+ if (_isCurrentLoad()) _loadingSessionId = null;
// #2971: re-arm the still-displayed session's stream (defensive — harmless
// if the 401 redirect is already tearing the page down). Idempotent.
_rearmActiveSessionStream();
return;
}
// Stale response? A newer loadSession() call has already started (#1060).
- if (_loadingSessionId !== sid) {
+ if (!_isCurrentLoad()) {
// #2971: a newer in-flight load owns the final stream arming, but until it
// assigns S.session and reaches startSessionStream() the currently-shown
// session must not be left stream-dead by our top-of-function teardown.
@@ -1660,7 +1667,7 @@ async function loadSession(sid){
// cross-profile continuation can't poison restore state with an unusable id.
const continuationSid=(data.session&&data.session.continuation_session_id)||'';
if(continuationSid&&continuationSid!==sid&&!opts.skipContinuationResolve){
- _loadingSessionId=null;
+ if (_isCurrentLoad()) _loadingSessionId=null;
return loadSession(continuationSid,{...opts,skipLineageResolve:true,skipContinuationResolve:true,force:true});
}
S.session=data.session;
@@ -1802,10 +1809,18 @@ async function loadSession(sid){
// this session's INFLIGHT snapshot, not leave prior-session rows in place.
if(typeof clearLiveToolCards==='function') clearLiveToolCards();
try {
- await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded});
+ await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded, loadGeneration:_loadGeneration});
} catch(e) {
+ if (!_isCurrentLoad()) {
+ _rearmActiveSessionStream();
+ return;
+ }
S.messages=inflightMessages;
}
+ if (!_isCurrentLoad()) {
+ _rearmActiveSessionStream();
+ return;
+ }
const liveTailPrepared=_prepareRunningLiveTail(S.messages,inflightMessages);
if(liveTailPrepared){
S.messages=_dropCurrentTurnAssistantMessages(S.messages);
@@ -1838,7 +1853,7 @@ async function loadSession(sid){
let didReconnect=false;
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
INFLIGHT[sid].reattach=false;
- if (_loadingSessionId !== sid) return;
+ if (!_isCurrentLoad()) return;
didReconnect=true;
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
}
@@ -1900,8 +1915,12 @@ async function loadSession(sid){
// "messages already populated" early-return inside _ensureMessagesLoaded
// does NOT skip the swap to the new transcript.
try {
- await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded});
+ await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded, loadGeneration:_loadGeneration});
} catch (e) {
+ if (!_isCurrentLoad()) {
+ _rearmActiveSessionStream();
+ return;
+ }
// Network errors, server failures, or SSE drops (Chrome error codes 4/5)
// can cause _ensureMessagesLoaded to throw. Without a try/catch here the
// "Loading conversation..." div injected at the top of loadSession would
@@ -1911,11 +1930,11 @@ async function loadSession(sid){
_msgInner.innerHTML = '
Failed to load messages. Try switching sessions or refreshing.
';
}
if (typeof showToast === 'function') showToast('Failed to load conversation messages', 3000, 'error');
- if (_loadingSessionId === sid) _loadingSessionId = null;
+ if (_isCurrentLoad()) _loadingSessionId = null;
return;
}
// Stale? A newer loadSession() call has already started (#1060).
- if (_loadingSessionId !== sid) return;
+ if (!_isCurrentLoad()) return;
// Restore any queued message that survived page refresh or tab restore.
if(typeof queueSessionMessage==='function'){
@@ -2032,7 +2051,7 @@ async function loadSession(sid){
}
// Clear the in-flight session marker now that this load has completed (#1060).
- if (_loadingSessionId === sid) _loadingSessionId = null;
+ if (_isCurrentLoad()) _loadingSessionId = null;
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
@@ -2737,6 +2756,9 @@ async function _ensureMessagesLoaded(sid, opts) {
// _ensureMessagesLoaded to fetch and SWAP the new transcript into
// S.messages in a single frame.
opts = opts || {};
+ const _loadGeneration = Number.isFinite(opts.loadGeneration) ? Number(opts.loadGeneration) : null;
+ const _ownsLoad = () => _loadingSessionId === sid && (_loadGeneration === null || _loadSessionGeneration === _loadGeneration);
+ if (!_ownsLoad()) return;
// Already have messages? (e.g. from INFLIGHT restore path, already set)
if (!opts.force && S.messages && S.messages.length > 0 && S.messages[0] && S.messages[0].role) {
_clearSameSessionForceReloadHint(sid);
@@ -2758,6 +2780,7 @@ async function _ensureMessagesLoaded(sid, opts) {
} finally {
_clearSameSessionForceReloadHint(sid);
}
+ if (!_ownsLoad()) return;
// Guard: api() may have redirected (401) and returned undefined.
if (!data || !data.session) return;
_messagesTruncated = !!data.session._messages_truncated;
diff --git a/tests/test_cross_session_message_load_isolation.py b/tests/test_cross_session_message_load_isolation.py
new file mode 100644
index 00000000..00a3329c
--- /dev/null
+++ b/tests/test_cross_session_message_load_isolation.py
@@ -0,0 +1,650 @@
+"""Regression tests for cross-session transcript isolation in loadSession.
+
+The checks lock behavior at both levels:
+
+1) Structural guard checks in ``loadSession()`` and ``_ensureMessagesLoaded()``
+ around ownership tokens and catch-path mutations.
+2) Runtime ordering/catch coverage using an executable Node harness to reproduce
+ old->new load overlap and stale rejected continuation behavior.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+REPO = Path(__file__).resolve().parents[1]
+SESSIONS_SRC = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
+NODE = shutil.which("node")
+
+
+def _extract_function(source: str, name: str) -> str:
+ """Return the full function source for ``name`` from a single js file.
+
+ Brace-depth tracking handles nested blocks and avoids fragile substring
+ matching in the large, hand-formatted source file.
+ """
+ marker = f"async function {name}("
+ start = source.find(marker)
+ if start < 0:
+ marker = f"function {name}("
+ start = source.find(marker)
+ assert start >= 0, f"{name} not found in sessions.js"
+
+ brace_start = source.find("{", start)
+ assert brace_start >= 0, f"function {name} is missing '{{'"
+
+ depth = 0
+ in_string = None
+ escaped = False
+ in_line_comment = False
+ in_block_comment = False
+
+ for index in range(brace_start, len(source)):
+ ch = source[index]
+ nxt = source[index + 1] if index + 1 < len(source) else ""
+
+ if in_line_comment:
+ if ch == "\n":
+ in_line_comment = False
+ continue
+ if in_block_comment:
+ if ch == "*" and nxt == "/":
+ in_block_comment = False
+ continue
+ if in_string:
+ if escaped:
+ escaped = False
+ elif ch == "\\":
+ escaped = True
+ elif ch == in_string:
+ in_string = None
+ continue
+
+ if ch == "/" and nxt == "/":
+ in_line_comment = True
+ continue
+ if ch == "/" and nxt == "*":
+ in_block_comment = True
+ continue
+ if ch in ('\'', '"', "`"):
+ in_string = ch
+ continue
+
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ return source[start : index + 1]
+
+ raise AssertionError(f"Could not extract function {name}")
+
+
+LOAD_SESSION_SRC = _extract_function(SESSIONS_SRC, "loadSession")
+ENSURE_MESSAGES_LOADED_SRC = _extract_function(SESSIONS_SRC, "_ensureMessagesLoaded")
+
+
+def _normalise_ws(s: str) -> str:
+ return re.sub(r"\s+", "", s)
+
+
+def test_loadsession_has_generation_token_and_forwards_to_ensure_messages_loaded():
+ body = LOAD_SESSION_SRC
+ assert "_loadSessionGeneration" in body, (
+ "loadSession() must use a global generation counter so superseded loads "
+ "can be rejected by continuation ownership checks"
+ )
+ assert "const _loadGeneration = ++_loadSessionGeneration" in body, (
+ "loadSession() must increment and capture per-call generation"
+ )
+ assert "const _isCurrentLoad = () => _loadingSessionId === sid && _loadSessionGeneration === _loadGeneration" in body
+ assert "loadGeneration:_loadGeneration" in body, (
+ "loadSession() must thread generation into _ensureMessagesLoaded()"
+ )
+ # Guard each await/catch branch so stale continuation cannot mutate shared pane state.
+ # Two calls exist in this function: INFLIGHT and idle branches.
+ norm = _normalise_ws(body)
+ assert norm.count("if(!_isCurrentLoad())") >= 6, (
+ "loadSession() should check ownership in multiple await/catch paths, "
+ "including stale _ensureMessagesLoaded catch branches"
+ )
+ ensure_call = _normalise_ws("await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded, loadGeneration:_loadGeneration});")
+ assert ensure_call in norm, (
+ "loadSession() must pass generation into _ensureMessagesLoaded() for stale-owner checks"
+ )
+ assert (
+ "showToast('Failed to load session" in LOAD_SESSION_SRC
+ or "showToast('Failed to load conversation messages" in LOAD_SESSION_SRC
+ ), "loadSession() should preserve toast-based failure paths"
+
+
+def test_ensure_messages_loaded_ownership_guard_pre_and_post_await():
+ body = ENSURE_MESSAGES_LOADED_SRC
+ assert "_loadSessionGeneration" in body, "_ensureMessagesLoaded should read generation"
+ assert "const _loadGeneration = Number.isFinite(opts.loadGeneration) ? Number(opts.loadGeneration) : null" in body
+ norm = _normalise_ws(body)
+ assert (
+ "_loadGeneration===null||_loadSessionGeneration===_loadGeneration" in norm
+ ), "_ensureMessagesLoaded must compare generation token"
+ assert norm.count("if(!_ownsLoad())return;") >= 2, (
+ "_ensureMessagesLoaded needs pre/post await ownership guards"
+ )
+ assert "_loadGeneration" in body, "_ensureMessagesLoaded should read generation from opts"
+
+
+_NODE_SCRIPT_TEMPLATE = r'''
+function makeHarness() {
+ const apiCalls = [];
+ const queue = [];
+ const pending = [];
+ function enqueue(url, value, mode="resolve") {
+ const defer = { url: String(url), value, mode, resolved: false };
+ defer.promise = new Promise((resolve, reject) => {
+ defer._resolve = resolve;
+ defer._reject = reject;
+ });
+ queue.push(defer);
+ return defer;
+ }
+
+ async function api(url) {
+ apiCalls.push(String(url));
+ const entry = queue.shift();
+ if (!entry) {
+ throw new Error('Unexpected API call: ' + String(url));
+ }
+ if (entry.url !== String(url)) {
+ throw new Error('API order mismatch, expected ' + entry.url + ', got ' + String(url));
+ }
+ pending.push(entry);
+ return entry.promise;
+ }
+
+ return { api, apiCalls, enqueue, pending };
+}
+
+function snapshotState() {
+ return {
+ sid: S.session && S.session.session_id,
+ messages: Array.isArray(S.messages) ? S.messages.map((m) => (m && m.role ? String(m.content || '') : null)).filter(Boolean) : [],
+ toolCalls: Array.isArray(S.toolCalls) ? S.toolCalls.slice() : [],
+ truncated: _messagesTruncated,
+ oldestIdx: _oldestIdx,
+ loadingSid: _loadingSessionId,
+ loadingGeneration: _loadSessionGeneration,
+ msgInner: _msgInner.innerHTML,
+ toastCalls: toastCalls.slice(),
+ rearmCalls,
+ apiCalls: apiHost.apiCalls.slice(),
+ clearHintCalls,
+ visibleCacheClears,
+ liveCardClears,
+ toolSyncCalls,
+ };
+}
+
+function createEnvironment() {
+ globalThis.INFLIGHT = {};
+ globalThis.S = {
+ session: { session_id: 'sid-init', message_count: 0 },
+ messages: [{ role: 'assistant', content: 'seed' }],
+ toolCalls: [],
+ pendingFiles: [],
+ busy: false,
+ activeStreamId: null,
+ };
+ globalThis._loadingSessionId = null;
+ globalThis._loadingOlder = false;
+ globalThis._loadSessionGeneration = 0;
+ globalThis._pendingCarryForwardSnapshot = null;
+ globalThis._messagesTruncated = false;
+ globalThis._oldestIdx = 0;
+ globalThis._messageRenderWindowSize = 0;
+ globalThis._messageReloadLimitForSession = () => 2;
+ globalThis._currentMessageRenderWindowSize = () => 1;
+ globalThis._messageRenderableMessageCount = () => 2;
+
+ globalThis._rearmActiveSessionStream = () => { rearmCalls += 1; };
+ globalThis.stopApprovalPolling = () => {};
+ globalThis.hideApprovalCard = () => {};
+ globalThis.stopSessionStream = () => {};
+ globalThis._yoloEnabled = false;
+ globalThis._updateYoloPill = () => {};
+ globalThis.stopClarifyPolling = () => {};
+ globalThis.hideClarifyCard = () => {};
+ globalThis._saveComposerDraftNow = () => Promise.resolve();
+ globalThis._sessionProfileMismatchFromError = () => null;
+ globalThis._switchProfileForSessionLoad = async () => {};
+ globalThis._clearSameSessionForceReloadHint = () => { clearHintCalls += 1; };
+ globalThis._clearStuckSessionOnBoot = () => {};
+ globalThis._setSessionViewedCount = () => {};
+ globalThis.scheduleTodosRefresh = () => {};
+ globalThis.startSessionStream = () => {};
+ globalThis.syncTopbar = () => {};
+ globalThis._captureSameSessionForceReloadHint = () => {};
+ globalThis._resolveSessionModelForDisplaySoon = () => {};
+ globalThis._setSessionCompletionUnread = () => {};
+ globalThis._clearSessionCompletionUnread = () => {};
+ globalThis._setActiveSessionUrl = () => {};
+ globalThis._deferWorkspaceRefreshForSession = () => {};
+ globalThis._sessionListRender = () => {};
+ globalThis._setSessionToolset = () => {};
+ globalThis._applyPendingSessionModelForSession = () => {};
+ globalThis.populateModelDropdown = () => {};
+ globalThis._deferSessionSideEffect = (sid, fn) => Promise.resolve(fn());
+ globalThis._hydrateTodosFromSession = () => {};
+ globalThis._resolveLineage = () => {};
+ globalThis._clearPendingSelections = () => {};
+ globalThis._clearQueueCardDisplay = () => {};
+ globalThis._syncTodosForSession = () => {};
+ globalThis._clearAllTodosFromSession = () => {};
+ globalThis._setSessionModelFromSession = () => {};
+ globalThis._clearEmptyComposerModelOverride = () => {};
+ globalThis._deferSessionProfileSwitch = () => {};
+ globalThis._resolveSessionSideEffect = () => {};
+
+
+ globalThis._clearMessageCache = () => {};
+ globalThis._syncToolCallsForLoadedMessages = (msgs, toolCalls) => {
+ toolSyncCalls += 1;
+ S.toolCalls = [];
+ if (Array.isArray(toolCalls)) {
+ S.toolCalls = toolCalls.map((tc) => ({ ...tc, done: true }));
+ }
+ };
+ globalThis.clearVisibleMessageRowCache = () => { visibleCacheClears += 1; };
+ globalThis.clearLiveToolCards = () => { liveCardClears += 1; };
+
+ globalThis._syncCtxIndicator = () => {};
+ globalThis._renderPendingPromptsForActiveSession = () => {};
+ globalThis._restoreComposerDraft = () => {};
+ globalThis.renderSessionArtifacts = () => {};
+ globalThis.renderMessages = () => {};
+ globalThis._checkAndShowHandoffHint = () => {};
+ globalThis._hideHandoffHint = () => {};
+ globalThis._isMessagingSession = () => true;
+ globalThis._clearDeferredActiveSessionExternalRefresh = () => {};
+
+ globalThis.setStatus = () => {};
+ globalThis.setComposerStatus = () => {};
+ globalThis.setBusy = () => {};
+ globalThis.updateSendBtn = () => {};
+ globalThis.updateQueueBadge = () => {};
+ globalThis.startApprovalPolling = () => {};
+ globalThis.startClarifyPolling = () => {};
+ globalThis._fetchYoloState = () => {};
+
+ globalThis._resolveSessionIdFromSidebarLineage = (sid) => sid;
+ globalThis._resolveSessionLineage = (sid) => sid;
+
+ globalThis._messageReloadLimitForSession = () => 2;
+
+ globalThis._msgInner = { innerHTML: 'INIT_LOADING' };
+ const _msgInput = { value: '' };
+ globalThis.$ = (id) => {
+ if (id === 'msgInner') return _msgInner;
+ if (id === 'msg') return _msgInput;
+ return null;
+ };
+
+ globalThis.autoResize = () => {};
+ globalThis.showToast = (msg) => {
+ toastCalls.push(String(msg));
+ };
+
+ globalThis.window = {};
+ globalThis.history = { replaceState: () => {} };
+ globalThis.localStorage = {
+ removeItem: () => {},
+ setItem: () => {},
+ getItem: () => null,
+ };
+ globalThis._appRootPath = () => '/';
+
+ rearmCalls = 0;
+ clearHintCalls = 0;
+ visibleCacheClears = 0;
+ liveCardClears = 0;
+ toolSyncCalls = 0;
+ toastCalls = [];
+}
+
+let rearmCalls = 0;
+let clearHintCalls = 0;
+let visibleCacheClears = 0;
+let liveCardClears = 0;
+let toolSyncCalls = 0;
+let toastCalls = [];
+
+// Source under test
+__LOAD_SESSION_SRC__
+__ENSURE_MESSAGES_LOADED_SRC__
+
+async function waitForQueued(apiHost, url) {
+ const target = String(url);
+ while (!apiHost.pending.some((entry) => entry.url === target)) {
+ await Promise.resolve();
+ }
+}
+
+const API_BEACON_META = {
+ session: {
+ session_id: 'sid-beacon',
+ message_count: 12,
+ active_stream_id: null,
+ resolve_model: 'qwen/qwq-32b-instruct',
+ },
+};
+
+const API_BEACON_MSGS = {
+ session: {
+ session_id: 'sid-beacon',
+ _messages_truncated: true,
+ _messages_offset: 7,
+ messages: [{ role: 'assistant', content: 'stale-beacon-transcript' }],
+ message_count: 12,
+ tool_calls: [{ name: 'tool-beacon-stale' }],
+ },
+};
+
+const API_BEACON_INFLIGHT_STATE = {
+ messages: [
+ {
+ role: 'assistant',
+ content: 'beacon-inflight-tail',
+ _live: true,
+ },
+ ],
+ uploaded: [],
+ toolCalls: [{ name: 'tool-beacon-inflight' }],
+};
+
+const API_ATLAS_META = {
+ session: {
+ session_id: 'sid-atlas',
+ message_count: 21,
+ active_stream_id: null,
+ resolve_model: 'qwen/qwq-32b-instruct',
+ },
+};
+
+const API_ATLAS_MSGS = {
+ session: {
+ session_id: 'sid-atlas',
+ _messages_truncated: false,
+ _messages_offset: 98,
+ messages: [{ role: 'assistant', content: 'new-active-transcript' }],
+ message_count: 21,
+ tool_calls: [{ name: 'tool-atlas' }],
+ },
+};
+
+const API_ATLAS_RELOAD_META = {
+ session: {
+ session_id: 'sid-atlas',
+ message_count: 31,
+ active_stream_id: null,
+ resolve_model: 'qwen/qwq-32b-instruct',
+ },
+};
+
+const API_ATLAS_RELOAD_MSGS = {
+ session: {
+ session_id: 'sid-atlas',
+ _messages_truncated: true,
+ _messages_offset: 33,
+ messages: [{ role: 'assistant', content: 'reloaded-active-transcript' }],
+ message_count: 31,
+ tool_calls: [{ name: 'tool-atlas-new' }],
+ },
+};
+
+function buildMessageUrl(sid, mode, suffix='') {
+ const base = `/api/session?session_id=${encodeURIComponent(sid)}&messages=${mode}&resolve_model=0`;
+ if (mode === 0) return base;
+ return `${base}&msg_limit=${_messageReloadLimitForSession()}&expand_renderable=1${suffix}`;
+}
+
+function makeCrossSessionCalls(apiHost) {
+ return {
+ beaconMeta: apiHost.enqueue(buildMessageUrl('sid-beacon', 0)),
+ beaconMsgs: apiHost.enqueue(buildMessageUrl('sid-beacon', 1)),
+ atlasMeta: apiHost.enqueue(buildMessageUrl('sid-atlas', 0)),
+ atlasMsgs: apiHost.enqueue(buildMessageUrl('sid-atlas', 1)),
+ };
+}
+
+function runCrossSessionOrderingBase({seedBeaconInflight, resolveBeaconMsgsBeforeAtlasMeta}) {
+ createEnvironment();
+ if (seedBeaconInflight) {
+ INFLIGHT['sid-beacon'] = JSON.parse(JSON.stringify(API_BEACON_INFLIGHT_STATE));
+ }
+
+ const apiHost = makeHarness();
+ globalThis.apiHost = apiHost;
+ globalThis.api = apiHost.api;
+
+ const calls = makeCrossSessionCalls(apiHost);
+
+ const first = loadSession('sid-beacon', { force: true });
+ return (async () => {
+ await waitForQueued(apiHost, calls.beaconMeta.url);
+ calls.beaconMeta._resolve(API_BEACON_META);
+
+ await waitForQueued(apiHost, calls.beaconMsgs.url);
+ const second = loadSession('sid-atlas', { force: true });
+ await waitForQueued(apiHost, calls.atlasMeta.url);
+
+ if (resolveBeaconMsgsBeforeAtlasMeta) {
+ calls.beaconMsgs._resolve(API_BEACON_MSGS);
+ // Wait for the stale first load continuation to process so we can continue the
+ // Atlas path from a clearly stale state.
+ await first;
+ calls.atlasMeta._resolve(API_ATLAS_META);
+ await waitForQueued(apiHost, calls.atlasMsgs.url);
+ } else {
+ calls.atlasMeta._resolve(API_ATLAS_META);
+ await waitForQueued(apiHost, calls.atlasMsgs.url);
+ calls.beaconMsgs._resolve(API_BEACON_MSGS);
+ }
+
+ calls.atlasMsgs._resolve(API_ATLAS_MSGS);
+ await Promise.all([first, second]);
+
+ return {
+ finalSid: S.session && S.session.session_id,
+ messages: snapshotState().messages,
+ toolCalls: snapshotState().toolCalls,
+ truncated: snapshotState().truncated,
+ oldestIdx: snapshotState().oldestIdx,
+ msgInner: snapshotState().msgInner,
+ toastCalls: snapshotState().toastCalls,
+ apiCalls: snapshotState().apiCalls,
+ loadingSid: snapshotState().loadingSid,
+ loadingGeneration: snapshotState().loadingGeneration,
+ rearmCalls: snapshotState().rearmCalls,
+ };
+ })();
+}
+
+async function runCrossSessionOrdering() {
+ return {
+ scenario: 'cross-session-ordering',
+ ...(await runCrossSessionOrderingBase({ seedBeaconInflight: true, resolveBeaconMsgsBeforeAtlasMeta: false })),
+ };
+}
+
+async function runObservedIdleCrossSessionOrdering() {
+ return {
+ scenario: 'observed-idle-cross-session-ordering',
+ ...(await runCrossSessionOrderingBase({ seedBeaconInflight: false, resolveBeaconMsgsBeforeAtlasMeta: true })),
+ };
+}
+
+async function runStaleRejectedIdleCatch() {
+ createEnvironment();
+ const apiHost = makeHarness();
+ globalThis.apiHost = apiHost;
+ globalThis.api = apiHost.api;
+
+ S.session = { session_id: 'sid-atlas', message_count: 0 };
+
+ const calls = {
+ firstMeta: apiHost.enqueue(buildMessageUrl('sid-atlas', 0)),
+ firstMsgs: apiHost.enqueue(buildMessageUrl('sid-atlas', 1)),
+ secondMeta: apiHost.enqueue(buildMessageUrl('sid-atlas', 0)),
+ secondMsgs: apiHost.enqueue(buildMessageUrl('sid-atlas', 1)),
+ };
+
+ const first = loadSession('sid-atlas', { force: true });
+ calls.firstMeta._resolve(API_ATLAS_META);
+
+ // Ensure the first load has entered the messages fetch and owns the pending API
+ // call before the superseding same-session load begins.
+ await waitForQueued(apiHost, calls.firstMsgs.url);
+
+ const second = loadSession('sid-atlas', { force: true });
+
+ // The stale first request rejects while the second newer request is in flight.
+ calls.firstMsgs._reject(new Error('owner lost while load was in-flight'));
+ calls.secondMeta._resolve(API_ATLAS_RELOAD_META);
+ calls.secondMsgs._resolve(API_ATLAS_RELOAD_MSGS);
+
+ await Promise.all([first, second]);
+
+ return {
+ scenario: 'stale-idle-catch',
+ finalSid: S.session && S.session.session_id,
+ messages: snapshotState().messages,
+ toolCalls: snapshotState().toolCalls,
+ truncated: snapshotState().truncated,
+ oldestIdx: snapshotState().oldestIdx,
+ msgInner: snapshotState().msgInner,
+ toastCalls: snapshotState().toastCalls,
+ apiCalls: snapshotState().apiCalls,
+ loadingSid: snapshotState().loadingSid,
+ loadingGeneration: snapshotState().loadingGeneration,
+ rearmCalls: snapshotState().rearmCalls,
+ };
+}
+
+async function runAll() {
+ return {
+ crossSessionOrdering: await runCrossSessionOrdering(),
+ observedIdleCrossSessionOrdering: await runObservedIdleCrossSessionOrdering(),
+ staleIdleCatch: await runStaleRejectedIdleCatch(),
+ };
+}
+
+runAll()
+ .then((r) => console.log(JSON.stringify(r)))
+ .catch((err) => {
+ console.error('NODE_ERROR', err && err.stack || err);
+ process.exit(1);
+ });
+'''
+
+
+def _run_node(script: str) -> dict:
+ assert NODE is not None, "node is required"
+ completed = subprocess.run(
+ [NODE, "--input-type=module", "-e", script],
+ cwd=str(REPO),
+ text=True,
+ capture_output=True,
+ timeout=60,
+ check=False,
+ )
+ assert completed.returncode == 0, completed.stderr or completed.stdout
+ output_lines = [line.strip() for line in completed.stdout.splitlines() if line.strip()]
+ assert output_lines, f"node produced no parseable output\nstdout={completed.stdout}\nstderr={completed.stderr}"
+ return json.loads(output_lines[-1])
+
+
+@pytest.mark.skipif(NODE is None, reason="node not on PATH")
+def test_loadsession_cross_session_ordering_and_stale_reject_behavior():
+ script = _NODE_SCRIPT_TEMPLATE.replace(
+ "__LOAD_SESSION_SRC__", LOAD_SESSION_SRC
+ ).replace(
+ "__ENSURE_MESSAGES_LOADED_SRC__", ENSURE_MESSAGES_LOADED_SRC
+ )
+ body = _run_node(script)
+
+ cross = body["crossSessionOrdering"]
+ stale = body["staleIdleCatch"]
+ observed = body["observedIdleCrossSessionOrdering"]
+
+ def _assert_atlas_wins(session_result, *, label):
+ assert session_result["finalSid"] == "sid-atlas", f"{label}: stale overlap should end on Atlas session"
+ assert session_result["messages"] == ["new-active-transcript"], (
+ f"{label}: stale Beacon transcript must not replace Atlas transcript"
+ )
+ assert session_result["toolCalls"] == [{"name": "tool-atlas", "done": True}], (
+ f"{label}: Atlas tool summary must apply on fresh load"
+ )
+ assert session_result["truncated"] is False and session_result["oldestIdx"] == 98, (
+ f"{label}: Atlas metadata should remain the active state"
+ )
+
+ # 1) Cross-session ordering: old (Beacon) loads first, but user advances to Atlas.
+ assert cross["apiCalls"][0] == "/api/session?session_id=sid-beacon&messages=0&resolve_model=0", (
+ "first API call should target old session's metadata"
+ )
+ assert cross["apiCalls"][1] == "/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", (
+ "beacon transcript request should queue before atlas metadata resolves"
+ )
+ assert cross["apiCalls"][2] == "/api/session?session_id=sid-atlas&messages=0&resolve_model=0", (
+ "second API call should target atlas metadata while stale beacon messages are in flight"
+ )
+ assert cross["apiCalls"][3] == "/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", (
+ "atlas should still fetch a transcript while beacon was stale"
+ )
+ assert cross["apiCalls"].count("/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1") == 1, (
+ "stale overlap should still issue the Beacon transcript call, but it must not win"
+ )
+ _assert_atlas_wins(cross, label="cross-session-ordering")
+
+ # 2) Observed idle-path race with no INFLIGHT: stale Beacon transcript returns
+ # before Atlas metadata, but ownership guard must still force Atlas fetch+swap.
+ assert observed["apiCalls"][0] == "/api/session?session_id=sid-beacon&messages=0&resolve_model=0", (
+ "idle-path race should start from old Beacon metadata"
+ )
+ assert observed["apiCalls"][1] == "/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", (
+ "Beacon transcript call should remain queued before Atlas metadata under observed race"
+ )
+ assert observed["apiCalls"][2] == "/api/session?session_id=sid-atlas&messages=0&resolve_model=0", (
+ "Atlas metadata must start while Beacon continuation returns stale"
+ )
+ assert observed["apiCalls"][3] == "/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", (
+ "Atlas transcript request must still issue despite stale Beacon return"
+ )
+ assert observed["apiCalls"].count("/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1") == 1, (
+ "stale Beacon transcript should occur once in observed race"
+ )
+ assert observed["apiCalls"].count("/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1") == 1, (
+ "Atlas transcript must be issued once once stale Beacon is processed first"
+ )
+ _assert_atlas_wins(observed, label="observed-idle-cross-session-ordering")
+ assert observed["toastCalls"] == [], "stale Beacon return in idle-path race should not show toast"
+
+ # 3) Stale rejected idle-branch catch must be ownership-guarded and not mutate shared pane.
+ assert stale["messages"] == ["reloaded-active-transcript"], "stale catch must not keep stale transcript"
+ assert stale["toolCalls"] == [{"name": "tool-atlas-new", "done": True}], "stale catch must not overwrite tool state"
+ assert stale["truncated"] is True and stale["oldestIdx"] == 33, "active owner should install latest metadata"
+ assert stale["msgInner"] == "INIT_LOADING", (
+ "stale reject from superseded load must not write failure placeholder"
+ )
+ assert stale["toastCalls"] == [], "stale reject must not surface toast for superseded load"
+ assert stale["apiCalls"].count(
+ "/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1"
+ ) == 2, "both old and active loads should have attempted message fetch"
+
+ assert cross["loadingSid"] is None, "load marker should be cleared after successful completion"
+ assert stale["loadingSid"] is None, "load marker should be cleared after stale reject + re-owner completion"
diff --git a/tests/test_issue3993_session_load_self_heal.py b/tests/test_issue3993_session_load_self_heal.py
index 1e2213a4..c9c4318d 100644
--- a/tests/test_issue3993_session_load_self_heal.py
+++ b/tests/test_issue3993_session_load_self_heal.py
@@ -88,7 +88,7 @@ def test_stale_load_guard_present_before_self_heal():
# loadSession catch block for the stale-load guard.
heal_idx = js.index("_clearStuckSessionOnBoot(sid, currentSid);")
block = js[heal_idx - 2400: heal_idx + 60]
- guard = "if (_loadingSessionId !== sid) {"
+ guard = "if (!_isCurrentLoad()) {"
assert guard in block, "stale-load guard missing from the loadSession catch block"
# The guard must come BEFORE the self-heal call (so a superseded load can't clear).
assert block.index(guard) < block.index("_clearStuckSessionOnBoot(sid, currentSid);"), \
diff --git a/tests/test_issue5177_hidden_tab_blank_gap.py b/tests/test_issue5177_hidden_tab_blank_gap.py
index 4a0bc8cb..64b6eaeb 100644
--- a/tests/test_issue5177_hidden_tab_blank_gap.py
+++ b/tests/test_issue5177_hidden_tab_blank_gap.py
@@ -78,6 +78,27 @@ def _refresh_block(compact: str) -> str:
raise AssertionError("refreshActiveSessionIfExternallyUpdated braces did not balance")
+def _ensure_messages_loaded_block(compact: str) -> str:
+ marker_named = "asyncfunction_ensureMessagesLoaded(sid,opts){"
+ marker_arglist = "asyncfunction_ensureMessagesLoaded(sid){"
+ start = compact.find(marker_named)
+ if start == -1:
+ start = compact.find(marker_arglist)
+ assert start != -1, "_ensureMessagesLoaded definition not found"
+ i = compact.find("{", start)
+ assert i != -1, "_ensureMessagesLoaded opening brace not found"
+ depth = 0
+ for j in range(i, len(compact)):
+ c = compact[j]
+ if c == "{":
+ depth += 1
+ elif c == "}":
+ depth -= 1
+ if depth == 0:
+ return compact[start : j + 1]
+ raise AssertionError("_ensureMessagesLoaded braces did not balance")
+
+
def test_keep_stale_until_loaded_flag_computed_in_loadsession():
# The flag must be computed by AND-ing the caller's opts with
# sameSessionForceReload — cross-session switches MUST keep clearing
@@ -137,7 +158,7 @@ def test_ensure_messages_loaded_called_with_keep_stale_flag():
# cannot skip the swap when stale messages are still in place.
block = _load_session_block(_compact(SESSIONS_JS))
# Both INFLIGHT and idle paths.
- assert block.count("await_ensureMessagesLoaded(sid,{force:_keepStaleUntilLoaded})") == 2
+ assert block.count("await_ensureMessagesLoaded(sid,{force:_keepStaleUntilLoaded,loadGeneration:_loadGeneration})") == 2
def test_ensure_messages_loaded_supports_force_override():
@@ -150,14 +171,7 @@ def test_ensure_messages_loaded_supports_force_override():
# greptile P2 r3393… on #5189) and the historical `arguments[1]` shape
# (for callers preserving an existing public signature). The required
# invariant is the EARLY-RETURN being gated on opts.force.
- marker_named = "asyncfunction_ensureMessagesLoaded(sid,opts){"
- marker_arglist = "asyncfunction_ensureMessagesLoaded(sid){"
- start = compact.find(marker_named)
- if start == -1:
- start = compact.find(marker_arglist)
- assert start != -1, "_ensureMessagesLoaded definition not found"
- # Take a generously-sized window for the early-return region.
- region = compact[start: start + 1000]
+ region = _ensure_messages_loaded_block(compact)
# Either explicit named param `opts` (preferred), or arguments[1] fallback,
# both must coerce to an object so opts.force is safe to read.
assert (
diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py
index 79eb9113..b0446809 100644
--- a/tests/test_issue856_background_completion_unread.py
+++ b/tests/test_issue856_background_completion_unread.py
@@ -519,7 +519,7 @@ def test_completion_unread_clears_only_when_session_is_opened():
assert load_idx != -1, "loadSession not found"
load_block = SESSIONS_JS[load_idx:SESSIONS_JS.find("function _resolveSessionModelForDisplaySoon", load_idx)]
- stale_guard_idx = load_block.find("if (_loadingSessionId !== sid) return;")
+ stale_guard_idx = load_block.find("if (!_isCurrentLoad()) return;")
clear_idx = load_block.find("_clearSessionCompletionUnread(S.session.session_id);")
set_viewed_idx = load_block.find("_setSessionViewedCount(S.session.session_id")
diff --git a/tests/test_issue_new_chat_draft_restore.py b/tests/test_issue_new_chat_draft_restore.py
index 7f6c7436..800ce779 100644
--- a/tests/test_issue_new_chat_draft_restore.py
+++ b/tests/test_issue_new_chat_draft_restore.py
@@ -124,12 +124,12 @@ def test_pre_switch_draft_flush_rechecks_stale_loading_guard():
"""The awaited draft-save in loadSession yields the event loop. On a rapid
session switch (B then quickly C) the stale B continuation must bail out
before the destructive state-clearing block, or it would wipe the
- freshly-loaded C state. The guard is `if (_loadingSessionId !== sid) return;`
+ freshly-loaded C state. The guard is `if (!_isCurrentLoad()) return;`
placed AFTER the awaited save and BEFORE the `S.messages = []` clear
(Codex pre-release CORE catch, #3471)."""
body = _load_session_clear_block()
await_idx = body.find("await _saveComposerDraftNow(currentSid")
- guard_idx = body.find("if (_loadingSessionId !== sid) return;", await_idx)
+ guard_idx = body.find("if (!_isCurrentLoad()) return;", await_idx)
clear_idx = body.find("S.messages = [];", await_idx)
assert await_idx != -1, "pre-switch awaited draft save not found"
assert guard_idx != -1, "stale-loading guard missing after the awaited draft save"