* 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>
665 lines
24 KiB
Python
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"
|