HERMES-GIELIE/tests/test_cross_session_message_load_isolation.py

687 lines
26 KiB
Python
Raw Normal View History

2026-07-10 15:26:36 +00:00
"""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")
INFLIGHT_HAS_VISIBLE_STATE_SRC = _extract_function(SESSIONS_SRC, "_inflightHasVisibleLiveState")
SELECT_LIVE_RECOVERY_INFLIGHT_SRC = _extract_function(SESSIONS_SRC, "_selectLiveRecoveryInflight")
fix(#6419): route every recovery path through one pending_user_message merge helper The mid-stream reconnect path could render the just-sent user message below the live streaming assistant response it triggered, because `refreshSession()` appended `pending_user_message` at the end of `S.messages` unconditionally while `loadSession()` correctly inserted it before the first live assistant message. When the client went through its reconnect/recovery path mid-turn (typical on iOS PWA screen lock/resume) the live assistant tail rendered above the prompt. Centralize every recovery/reconnect reinsertion through one identity-aware helper (`_mergePendingSessionMessage`) that: - inserts the owning user turn before its live assistant/Worklog tail, - appends only when no live tail exists, - deduplicates against the current tail user row, - remains idempotent across repeated resume probes. `refreshSession()` now calls the same helper instead of pushing at the end. `loadSession()` continues to use the helper unchanged. Adds `tests/test_issue6419_pending_user_reconnect_order.py` pinning: - the helper is defined exactly once at module top level, - it inserts before live assistant and falls back to append, - `refreshSession()` uses the helper and does not push unconditionally, - `loadSession()` still merges before renderMessages in the INFLIGHT reattach branch. Renames the inner end-marker in `tests/test_gateway_sse_reconnect_dedupe.py` to a stable inside-loadSession boundary (`// Phase 2a:`) since the helper is no longer nested.
2026-07-23 14:31:56 +00:00
MERGE_PENDING_SESSION_MESSAGE_SRC = _extract_function(SESSIONS_SRC, "_mergePendingSessionMessage")
2026-07-10 15:26:36 +00:00
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;
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-18 03:15:40 +00:00
// 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;
Release: msg_limit ceiling metadata decoupling (#6214, @webtecnica) (#6216) * feat: expose msg_limit ceiling via /api/session metadata, frontend reads dynamically (#6177) Backend exposes _MAX_MSG_LIMIT as _msg_limit_max in every /api/session response. Frontend reads it dynamically, falling back to _MSG_LIMIT_MAX for older servers. This removes the hand-mirrored coupling between the two layers. Removed test_msg_limit_ceiling_drift.py since the mirror pattern that required the drift guard is replaced by dynamic metadata. This is the standalone metadata-decoupling piece from #6206, without the clamp/paging that already shipped in exp-v0.52.98 via #6152/#6154. * fix(session): declare _msgLimitMax at module scope + CHANGELOG + gate fixes (#6214 follow-up) The submitted PR used _msgLimitMax at two read sites (boundedReloadLimit in _ensureMessagesLoaded, useBeforePaging in _loadOlderMessages) but never declared it and read it before assignment -> undefined on cold load -> full-transcript fetch every load (regression) + implicit global. Declared `let _msgLimitMax = _MSG_LIMIT_MAX;` at module scope so the reload-width paths always read a defined value (the static fallback) until the server's _msg_limit_max lands. Also: defined the ceiling globals in the two inline node-harness tests that copy _ensureMessagesLoaded's body (test_cross_session_message_load_isolation, test_session_unread_dot_on_visit), updated the source-string assertion in test_webui_external_refresh_frontend, and added 3 decoupling tests (backend field present, module-scope declaration + fallback, both paths read the live ceiling) replacing the deleted drift-guard. Gate: Codex adversarial SAFE TO SHIP (executable probes: cold-load fallback, mixed-version omission, live update, cross-session isolation, over-ceiling bare refresh, msg_before row preservation). Full sharded suite green. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-18 04:21:52 +00:00
// #6177: _msgLimitMax is a module-scope `let` (live server-advertised ceiling,
// defaulting to _MSG_LIMIT_MAX). It's read by _ensureMessagesLoaded's
// boundedReloadLimit and _loadOlderMessages's useBeforePaging; the harness
// injects only the extracted functions, not module-level lets, so define it
// here or those reads resolve undefined -> wrong fetch URL -> ordered-api stall.
globalThis._msgLimitMax = 500;
2026-07-10 15:26:36 +00:00
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;
2026-07-10 15:26:36 +00:00
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
__INFLIGHT_HAS_VISIBLE_STATE_SRC__
__SELECT_LIVE_RECOVERY_INFLIGHT_SRC__
fix(#6419): route every recovery path through one pending_user_message merge helper The mid-stream reconnect path could render the just-sent user message below the live streaming assistant response it triggered, because `refreshSession()` appended `pending_user_message` at the end of `S.messages` unconditionally while `loadSession()` correctly inserted it before the first live assistant message. When the client went through its reconnect/recovery path mid-turn (typical on iOS PWA screen lock/resume) the live assistant tail rendered above the prompt. Centralize every recovery/reconnect reinsertion through one identity-aware helper (`_mergePendingSessionMessage`) that: - inserts the owning user turn before its live assistant/Worklog tail, - appends only when no live tail exists, - deduplicates against the current tail user row, - remains idempotent across repeated resume probes. `refreshSession()` now calls the same helper instead of pushing at the end. `loadSession()` continues to use the helper unchanged. Adds `tests/test_issue6419_pending_user_reconnect_order.py` pinning: - the helper is defined exactly once at module top level, - it inserts before live assistant and falls back to append, - `refreshSession()` uses the helper and does not push unconditionally, - `loadSession()` still merges before renderMessages in the INFLIGHT reattach branch. Renames the inner end-marker in `tests/test_gateway_sse_reconnect_dedupe.py` to a stable inside-loadSession boundary (`// Phase 2a:`) since the helper is no longer nested.
2026-07-23 14:31:56 +00:00
__MERGE_PENDING_SESSION_MESSAGE_SRC__
2026-07-10 15:26:36 +00:00
__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);
});
'''
fix(#6419): route every recovery path through one pending_user_message merge helper The mid-stream reconnect path could render the just-sent user message below the live streaming assistant response it triggered, because `refreshSession()` appended `pending_user_message` at the end of `S.messages` unconditionally while `loadSession()` correctly inserted it before the first live assistant message. When the client went through its reconnect/recovery path mid-turn (typical on iOS PWA screen lock/resume) the live assistant tail rendered above the prompt. Centralize every recovery/reconnect reinsertion through one identity-aware helper (`_mergePendingSessionMessage`) that: - inserts the owning user turn before its live assistant/Worklog tail, - appends only when no live tail exists, - deduplicates against the current tail user row, - remains idempotent across repeated resume probes. `refreshSession()` now calls the same helper instead of pushing at the end. `loadSession()` continues to use the helper unchanged. Adds `tests/test_issue6419_pending_user_reconnect_order.py` pinning: - the helper is defined exactly once at module top level, - it inserts before live assistant and falls back to append, - `refreshSession()` uses the helper and does not push unconditionally, - `loadSession()` still merges before renderMessages in the INFLIGHT reattach branch. Renames the inner end-marker in `tests/test_gateway_sse_reconnect_dedupe.py` to a stable inside-loadSession boundary (`// Phase 2a:`) since the helper is no longer nested.
2026-07-23 14:31:56 +00:00
def _run_node(script: str, tmp_path: Path) -> dict:
2026-07-10 15:26:36 +00:00
assert NODE is not None, "node is required"
fix(#6419): route every recovery path through one pending_user_message merge helper The mid-stream reconnect path could render the just-sent user message below the live streaming assistant response it triggered, because `refreshSession()` appended `pending_user_message` at the end of `S.messages` unconditionally while `loadSession()` correctly inserted it before the first live assistant message. When the client went through its reconnect/recovery path mid-turn (typical on iOS PWA screen lock/resume) the live assistant tail rendered above the prompt. Centralize every recovery/reconnect reinsertion through one identity-aware helper (`_mergePendingSessionMessage`) that: - inserts the owning user turn before its live assistant/Worklog tail, - appends only when no live tail exists, - deduplicates against the current tail user row, - remains idempotent across repeated resume probes. `refreshSession()` now calls the same helper instead of pushing at the end. `loadSession()` continues to use the helper unchanged. Adds `tests/test_issue6419_pending_user_reconnect_order.py` pinning: - the helper is defined exactly once at module top level, - it inserts before live assistant and falls back to append, - `refreshSession()` uses the helper and does not push unconditionally, - `loadSession()` still merges before renderMessages in the INFLIGHT reattach branch. Renames the inner end-marker in `tests/test_gateway_sse_reconnect_dedupe.py` to a stable inside-loadSession boundary (`// Phase 2a:`) since the helper is no longer nested.
2026-07-23 14:31:56 +00:00
script_path = tmp_path / "cross-session-message-load-isolation.mjs"
script_path.write_text(script, encoding="utf-8")
2026-07-10 15:26:36 +00:00
completed = subprocess.run(
fix(#6419): route every recovery path through one pending_user_message merge helper The mid-stream reconnect path could render the just-sent user message below the live streaming assistant response it triggered, because `refreshSession()` appended `pending_user_message` at the end of `S.messages` unconditionally while `loadSession()` correctly inserted it before the first live assistant message. When the client went through its reconnect/recovery path mid-turn (typical on iOS PWA screen lock/resume) the live assistant tail rendered above the prompt. Centralize every recovery/reconnect reinsertion through one identity-aware helper (`_mergePendingSessionMessage`) that: - inserts the owning user turn before its live assistant/Worklog tail, - appends only when no live tail exists, - deduplicates against the current tail user row, - remains idempotent across repeated resume probes. `refreshSession()` now calls the same helper instead of pushing at the end. `loadSession()` continues to use the helper unchanged. Adds `tests/test_issue6419_pending_user_reconnect_order.py` pinning: - the helper is defined exactly once at module top level, - it inserts before live assistant and falls back to append, - `refreshSession()` uses the helper and does not push unconditionally, - `loadSession()` still merges before renderMessages in the INFLIGHT reattach branch. Renames the inner end-marker in `tests/test_gateway_sse_reconnect_dedupe.py` to a stable inside-loadSession boundary (`// Phase 2a:`) since the helper is no longer nested.
2026-07-23 14:31:56 +00:00
[NODE, str(script_path)],
2026-07-10 15:26:36 +00:00
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")
fix(#6419): route every recovery path through one pending_user_message merge helper The mid-stream reconnect path could render the just-sent user message below the live streaming assistant response it triggered, because `refreshSession()` appended `pending_user_message` at the end of `S.messages` unconditionally while `loadSession()` correctly inserted it before the first live assistant message. When the client went through its reconnect/recovery path mid-turn (typical on iOS PWA screen lock/resume) the live assistant tail rendered above the prompt. Centralize every recovery/reconnect reinsertion through one identity-aware helper (`_mergePendingSessionMessage`) that: - inserts the owning user turn before its live assistant/Worklog tail, - appends only when no live tail exists, - deduplicates against the current tail user row, - remains idempotent across repeated resume probes. `refreshSession()` now calls the same helper instead of pushing at the end. `loadSession()` continues to use the helper unchanged. Adds `tests/test_issue6419_pending_user_reconnect_order.py` pinning: - the helper is defined exactly once at module top level, - it inserts before live assistant and falls back to append, - `refreshSession()` uses the helper and does not push unconditionally, - `loadSession()` still merges before renderMessages in the INFLIGHT reattach branch. Renames the inner end-marker in `tests/test_gateway_sse_reconnect_dedupe.py` to a stable inside-loadSession boundary (`// Phase 2a:`) since the helper is no longer nested.
2026-07-23 14:31:56 +00:00
def test_loadsession_cross_session_ordering_and_stale_reject_behavior(tmp_path):
script = (
_NODE_SCRIPT_TEMPLATE.replace(
"__INFLIGHT_HAS_VISIBLE_STATE_SRC__", INFLIGHT_HAS_VISIBLE_STATE_SRC
)
.replace(
"__SELECT_LIVE_RECOVERY_INFLIGHT_SRC__", SELECT_LIVE_RECOVERY_INFLIGHT_SRC
)
fix(#6419): route every recovery path through one pending_user_message merge helper The mid-stream reconnect path could render the just-sent user message below the live streaming assistant response it triggered, because `refreshSession()` appended `pending_user_message` at the end of `S.messages` unconditionally while `loadSession()` correctly inserted it before the first live assistant message. When the client went through its reconnect/recovery path mid-turn (typical on iOS PWA screen lock/resume) the live assistant tail rendered above the prompt. Centralize every recovery/reconnect reinsertion through one identity-aware helper (`_mergePendingSessionMessage`) that: - inserts the owning user turn before its live assistant/Worklog tail, - appends only when no live tail exists, - deduplicates against the current tail user row, - remains idempotent across repeated resume probes. `refreshSession()` now calls the same helper instead of pushing at the end. `loadSession()` continues to use the helper unchanged. Adds `tests/test_issue6419_pending_user_reconnect_order.py` pinning: - the helper is defined exactly once at module top level, - it inserts before live assistant and falls back to append, - `refreshSession()` uses the helper and does not push unconditionally, - `loadSession()` still merges before renderMessages in the INFLIGHT reattach branch. Renames the inner end-marker in `tests/test_gateway_sse_reconnect_dedupe.py` to a stable inside-loadSession boundary (`// Phase 2a:`) since the helper is no longer nested.
2026-07-23 14:31:56 +00:00
.replace(
"__MERGE_PENDING_SESSION_MESSAGE_SRC__", MERGE_PENDING_SESSION_MESSAGE_SRC
)
.replace("__LOAD_SESSION_SRC__", LOAD_SESSION_SRC)
.replace("__ENSURE_MESSAGES_LOADED_SRC__", ENSURE_MESSAGES_LOADED_SRC)
2026-07-10 15:26:36 +00:00
)
fix(#6419): route every recovery path through one pending_user_message merge helper The mid-stream reconnect path could render the just-sent user message below the live streaming assistant response it triggered, because `refreshSession()` appended `pending_user_message` at the end of `S.messages` unconditionally while `loadSession()` correctly inserted it before the first live assistant message. When the client went through its reconnect/recovery path mid-turn (typical on iOS PWA screen lock/resume) the live assistant tail rendered above the prompt. Centralize every recovery/reconnect reinsertion through one identity-aware helper (`_mergePendingSessionMessage`) that: - inserts the owning user turn before its live assistant/Worklog tail, - appends only when no live tail exists, - deduplicates against the current tail user row, - remains idempotent across repeated resume probes. `refreshSession()` now calls the same helper instead of pushing at the end. `loadSession()` continues to use the helper unchanged. Adds `tests/test_issue6419_pending_user_reconnect_order.py` pinning: - the helper is defined exactly once at module top level, - it inserts before live assistant and falls back to append, - `refreshSession()` uses the helper and does not push unconditionally, - `loadSession()` still merges before renderMessages in the INFLIGHT reattach branch. Renames the inner end-marker in `tests/test_gateway_sse_reconnect_dedupe.py` to a stable inside-loadSession boundary (`// Phase 2a:`) since the helper is no longer nested.
2026-07-23 14:31:56 +00:00
body = _run_node(script, tmp_path)
2026-07-10 15:26:36 +00:00
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"