Files
hermes-webui/tests/test_cross_session_message_load_isolation.py
T
b470793473 Release: msg_limit ceiling + row-loss-safe pagination (#6152 + #6154, @rh-id) (#6213)
* fix(routes): clamp GET /api/session msg_limit to a server-side ceiling

Memory: ?msg_limit= had no upper bound — a client could request
msg_limit=1000000 (or the frontend's msg_limit=9999 outline-jump path) and
force the server to assemble and serialize an unbounded message payload. The
frontend's real pagination grows windows by ~30 at a time (initial load 30,
load-older +30, msg_before paging 30), so only the outline-jump and
pathological/hostile cases ask for huge values.

Add _MAX_MSG_LIMIT (500) and clamp at the query-parse site
(max(1, min(int(_msg_limit), _MAX_MSG_LIMIT))). The ceiling is generous — far
above any legitimate visible-row window — so real pagination is unaffected. The
existing _messages_truncated signal already covers the clamped case: when the
request exceeds the ceiling, _message_window_for_display returns
_messages_offset > 0 and the client learns more rows exist (load-older still
works). The bare no-msg_limit path is intentionally unchanged — it is the
documented "give me everything" escape hatch used by branch/undo/jump-to-start
flows that need the full transcript for correct global indices.

Test: tests/test_session_msg_limit_ceiling.py — the ceiling constant is
reasonable (100..2000), an over-ceiling request is clamped and signals
truncation, and legit sizes (5/30/60/100) are unaltered. Existing window/tail
tests (19) unchanged.

Contract Routing
- Task type: memory-bound fix (unbounded msg_limit request payload).
- Touched areas: GET /api/session query parsing (api/routes.py).
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md.
- State layer mutated: none (read-only endpoint response shaping).
- Contract change (additive): ?msg_limit=N is now clamped to _MAX_MSG_LIMIT=500.
  Compatibility: the only frontend caller above the ceiling is outline.js's
  msg_limit=9999, which now receives up to 500 rows + _messages_truncated=true
  (load-older still works). All other callers (30/60) are unaffected. The bare
  no-msg_limit path is unchanged.

Release note: GET /api/session now caps ?msg_limit at 500 rows server-side,
bounding the worst-case payload for oversized requests.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).

* fix(frontend): route msg_limit-heavy paths around the server ceiling

PR #6152 clamps GET /api/session ?msg_limit= to a max of 500 server-side. Two
frontend paths relied on unbounded msg_limit and silently broke for sessions
> 500 visible rows:

1. outline.js _jumpToMessage sent msg_limit=9999 as a "give me everything" hack
   so it could scroll to any message by absolute index (msg-user-<rawIdx>).
   Clamped to 500, the target row's DOM id doesn't exist for early messages in
   long sessions, and the jump silently no-ops (line 119 `if (r)` is falsy).
   Fix: drop msg_limit=9999 — use the bare messages=1 path, which #6152 left
   unbounded precisely for callers that need the full transcript. This restores
   exact pre-#6152 behavior for outline jumps.

2. sessions.js _loadOlderMessages grows msg_limit by +_INITIAL_MSG_LIMIT each
   load (30 -> 60 -> ... -> 500 -> clamped). Past 500 the server returns the
   same clamped tail, olderMsgs shrinks to 0, and endless-scroll / "Load
   earlier" silently stalls — the head becomes unreachable. Fix: once the grown
   requestedLimit meets/exceeds the new _MSG_LIMIT_MAX mirror (500), switch the
   primary fetch to msg_before paging (a fixed _INITIAL_MSG_LIMIT backward page
   keyed off _oldestIdx) — the same bounded paging the existing race-fallback
   uses, so the head stays reachable for arbitrarily long transcripts. The
   response is handled by the existing prepend path (tailMatches is naturally
   false for a page, so the !tailMatches branch reuses the fetched data).

Added _MSG_LIMIT_MAX = 500 constant in sessions.js mirroring backend
_MAX_MSG_LIMIT, documented as kept-in-sync.

Verification: node --check passes on both files; the backend window-contract
tests (test_session_tail_payload, test_session_message_window_renderable_tail,
19 tests) still pass. No JS test harness exists in this repo (vanilla JS,
manual browser verification per TESTING.md) — manual verification needed: open a
> 500-message session, (a) click an outline entry for an early message and
confirm it scrolls+flashes, (b) scroll up past 500 loaded rows and confirm
"Load earlier" keeps producing older messages without stalling.

Contract Routing
- Task type: frontend follow-up to a backend memory fix (#6152).
- Touched areas: outline jump-to-message (static/outline.js), endless-scroll /
  load-older (static/sessions.js _loadOlderMessages).
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md.
- State layer mutated: client-side S.messages (the active-session transcript),
  same as the existing load-older path. No backend state change.
- Scope boundaries: does not weaken the #6152 clamp. The bare messages=1 path
  (outline) was intentionally left unbounded by #6152; the msg_before paging
  path (load-older) is bounded by _INITIAL_MSG_LIMIT and never hits the ceiling.

Release note: fixes outline jump-to-message and endless scroll silently
breaking on sessions longer than 500 messages after the server-side msg_limit
ceiling (companion to #6152).

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).

* test(frontend): update static-source assertion for refactored _loadOlderMessages fetch

The static-source test test_session_message_loads_keep_explicit_longer_timeouts
asserted the exact multi-line shape of the _loadOlderMessages fetch strings.
The #6154 refactor wrapped them in a useBeforePaging ternary (tail-growth vs
msg_before paging), reindenting them — the contract the test guards (both
fetches keep timeoutMs:120000 for large state.db loads) is preserved, but the
literal source shape changed, so the assertion failed on shard 0.

Updated the assertions to match the new source shape while keeping the test's
intent: assert each fetch URL + its {timeoutMs:120000} survive in sessions.js.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).

* test(msg-limit): stopgap drift guard for _MSG_LIMIT_MAX === _MAX_MSG_LIMIT

Reviewer guidance (#6154 round-2): the stopgap drift-test belongs in THIS PR
(#6152) because _MAX_MSG_LIMIT is defined here. The frontend _MSG_LIMIT_MAX
mirror lives on #6154 (not master yet), so the assertion SKIPS when the JS
constant is absent and activates automatically once both branches land on master.

The guard: extract _MAX_MSG_LIMIT from api/routes.py and _MSG_LIMIT_MAX from
static/sessions.js via regex; assert equality when both present. If they drift,
load-older silently stalls at the wrong value (the exact regression #6154 fixed).
Retired by #6177 (metadata-exposure), which removes the mirror entirely.

Verified: on this branch the cross-check SKIPS (_MSG_LIMIT_MAX absent, as
expected — #6154 not landed) and the backend-existence test PASSES. Extraction
logic confirmed correct against simulated match (500==500 passes) and drift
(999!=500 fails) cases.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).

* fix(session): prevent silent row-loss in msg_limit ceiling paging (gate follow-up)

Adversarial review of #6152/#6154 reproduced two silent row-loss regressions in
static/sessions.js; both fixed here, plus a stale source-string assertion update
and the CHANGELOG entry:

1. _loadOlderMessages: gate the suffix-continuity heuristic on !useBeforePaging so
   a raw-row-heavy msg_before cursor page whose visible text repeats the current
   tail can't be misclassified as the cumulative tail and wholesale-replace it
   (previously dropped 470 of 940 rows). msg_before pages are always prepended.

2. loadSession same-session refresh: only apply the width-hint msg_limit when it
   is <= _MSG_LIMIT_MAX; above the ceiling the backend would clamp and silently
   shrink an already-loaded >500-row transcript to the last 500. Over-ceiling
   reloads now use the bare full-transcript path (no msg_limit / no expand_renderable).

3. tests/test_webui_external_refresh_frontend.py: update the reloadLimitParam
   source assertion to the boundedReloadLimit form.

Re-gate: Codex adversarial SAFE TO SHIP (940/940 rows retained on cursor paging,
1000/1000 on refresh, no seam duplication, small sessions still use the fast tail
window); targeted + sessions/pagination/regression suites green.

Co-authored-by: Ruby Hartono <rh-id@users.noreply.github.com>

* test: fix two node-harness tests broken by the boundedReloadLimit change

- test_issue3162_ensure_messages_loaded: replace the brittle fixed 4500-char
  window (which the boundedReloadLimit lines pushed the carry-forward past) with
  robust brace-balance function-body extraction.
- test_cross_session_message_load_isolation: define _MSG_LIMIT_MAX=500 in the node
  harness env; _ensureMessagesLoaded now references it, and an undefined value made
  boundedReloadLimit=null -> the fetch URL dropped msg_limit/expand_renderable and
  mismatched the ordered api() harness -> stall/timeout.

---------

Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Ruby Hartono <rh-id@users.noreply.github.com>
2026-07-17 20:15:40 -07:00

665 lines
24 KiB
Python

"""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;
// sessions.js module-level const, referenced by _ensureMessagesLoaded's
// boundedReloadLimit ceiling check (#6152/#6154). Not one of the extracted
// functions, so define it in the harness (matching the real value) or the
// reload-width path resolves it as undefined -> boundedReloadLimit=null ->
// the fetch URL drops msg_limit/expand_renderable and mismatches the
// enqueued buildMessageUrl(), stalling the ordered api() harness.
globalThis._MSG_LIMIT_MAX = 500;
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 = () => {};
// #4946: loadSession() now routes its viewed-count/unread clear through
// _acknowledgeSessionVisit(). This harness exercises cross-session load
// ordering + stale-reject, not unread-dot state, so stub it (and its
// same-session-guard predicate) to no-ops — mirroring the pre-existing
// _setSessionViewedCount / _clearSessionCompletionUnread stubs it replaced.
globalThis._acknowledgeSessionVisit = () => {};
globalThis._sessionVisitHasUnreadState = () => false;
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"