Merge remote-tracking branch 'origin/master' into paperclip/pr-5732
This commit is contained in:
commit
3c95b3a9d5
13 files changed
+822
-51
No files matched your search
@@ -5,6 +5,10 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A fast session switch can no longer paint the wrong conversation's messages into the pane you're looking at.** The chat pane is shared state, and `loadSession()` previously tracked ownership only by destination session id. If you switched sessions (or reloaded the same one) quickly, a slow transcript response from the earlier load could finish late and populate `S.messages` after a newer navigation had already taken over — leaving a split pane: the current session's header above another session's rows. Each `loadSession()` invocation now carries a monotonically increasing generation token, and every point that mutates the pane after an `await` re-checks it still owns the load (matching both session id *and* generation) before writing — so a stale continuation bails instead of hijacking the view. A redundant same-session click no longer cancels the authoritative in-flight refresh, and a stale response can't collapse a long transcript to its tail window. Thanks @starship-s. (#5889)
|
||||
|
||||
- **The model picker no longer mislabels a model as free when OpenRouter hasn't published complete pricing.** The OpenRouter free-tier detection treated a missing or `null` price field as `0`, so a model whose pricing metadata was absent or partial could be surfaced as "(free)" and added to the free-tier list. The check now fails closed — a candidate is only treated as free when both prompt and completion prices are present and genuinely zero — while models with an explicit `:free` id stay selectable. Thanks @igorroncevic. (#5882, #5880)
|
||||
|
||||
- **A session no longer gets stuck sending to the wrong provider after its stored provider becomes stale.** When a session's persisted model/provider no longer matches where that model actually lives (e.g. the model moved groups, or an older session carried a now-foreign provider), chat start now repairs the binding to the correct provider — but only on positive proof: it reroutes solely when the live model catalog shows the stored group genuinely lacks the model AND exactly one other healthy group owns it. It checks both the primary and overflow (`extra_models`) model buckets before deciding a group doesn't own a model, and it fails safe — preserving the stored provider without rerouting — whenever any relevant catalog probe failed (a transient discovery error is never treated as proof the model is absent). Ordinary matching-provider sends make no catalog call, and explicit/ambiguous/unknown/stored-owner picks are all left untouched. Thanks @rodboev. (#5873, #5731)
|
||||
|
||||
### Internal
|
||||
|
||||
+14
-8
@@ -6919,14 +6919,20 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa
|
||||
_mid = str(_item.get("id") or "").strip()
|
||||
if not _mid or _mid in seen_ids:
|
||||
continue
|
||||
_pricing = _item.get("pricing") or {}
|
||||
try:
|
||||
_is_free = (
|
||||
float(_pricing.get("prompt", "0") or "0") == 0
|
||||
and float(_pricing.get("completion", "0") or "0") == 0
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
_is_free = False
|
||||
_pricing = _item.get("pricing")
|
||||
_is_free = False
|
||||
if (
|
||||
isinstance(_pricing, dict)
|
||||
and "prompt" in _pricing
|
||||
and "completion" in _pricing
|
||||
):
|
||||
try:
|
||||
_is_free = (
|
||||
float(_pricing["prompt"]) == 0
|
||||
and float(_pricing["completion"]) == 0
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
_is_free = False
|
||||
# Also include explicit `:free` suffix variants
|
||||
_is_free = _is_free or _mid.endswith(":free")
|
||||
if not _is_free:
|
||||
|
||||
+41
-18
@@ -19,6 +19,11 @@ const ICONS={
|
||||
// responses from in-flight requests when the user switches sessions again
|
||||
// before the first request completes (#1060).
|
||||
let _loadingSessionId = null;
|
||||
// Each loadSession() invocation gets a monotonically increasing generation.
|
||||
// `_loadingSessionId` only tracks destination session_id, so same-session
|
||||
// concurrent loads can still race and overwrite each other unless we compare
|
||||
// the generation token as well.
|
||||
let _loadSessionGeneration = 0;
|
||||
// #3306: Snapshot of S.messages captured by loadSession() right before it
|
||||
// clears them on a force-reload of the active session. Consumed by
|
||||
// _ensureMessagesLoaded() when calling _carryForwardEphemeralTurnFields so
|
||||
@@ -1420,9 +1425,11 @@ async function loadSession(sid){
|
||||
// #2971: idempotent re-arm before the no-op guard revives a stream a prior
|
||||
// failed loadSession killed; no-ops on real switches.
|
||||
_rearmActiveSessionStream();
|
||||
if(currentSid===sid && !forceReload && !_loadingSessionId) return;
|
||||
if(currentSid===sid && !forceReload && (!_loadingSessionId || _loadingSessionId===sid)) return;
|
||||
// Mark this session as the in-flight load. Subsequent loadSession() calls
|
||||
// will overwrite this; stale awaits use the mismatch to bail out (#1060).
|
||||
const _loadGeneration = ++_loadSessionGeneration;
|
||||
const _isCurrentLoad = () => _loadingSessionId === sid && _loadSessionGeneration === _loadGeneration;
|
||||
_loadingSessionId = sid;
|
||||
if(currentSid!==sid&&typeof _uploadPendingFilesSyncProgressForSession==='function')_uploadPendingFilesSyncProgressForSession(sid);
|
||||
// Reset scroll state for fresh session navigation — the reader expects to
|
||||
@@ -1453,7 +1460,7 @@ async function loadSession(sid){
|
||||
// continuation can't wipe S.messages / write the loading placeholder /
|
||||
// close streams for the session the user actually landed on (#1060 guard,
|
||||
// extended to cover the new pre-switch await).
|
||||
if (_loadingSessionId !== sid) return;
|
||||
if (!_isCurrentLoad()) return;
|
||||
// Snapshot the live turn before msgInner is replaced. Preserves the activity
|
||||
// timer, partial response, and tool cards so switching back does not rebuild
|
||||
// the stream UI from scratch.
|
||||
@@ -1532,7 +1539,7 @@ async function loadSession(sid){
|
||||
} catch(e) {
|
||||
const profileMismatch=_sessionProfileMismatchFromError(e);
|
||||
if(profileMismatch && profileMismatch.profile && !opts.skipProfileResolve){
|
||||
if (_loadingSessionId !== sid) {
|
||||
if (!_isCurrentLoad()) {
|
||||
_rearmActiveSessionStream();
|
||||
return;
|
||||
}
|
||||
@@ -1544,11 +1551,11 @@ async function loadSession(sid){
|
||||
// navigated to a different session. If we no longer own the load, bail
|
||||
// before clearing _loadingSessionId or retrying so the stale
|
||||
// continuation can't hijack the UI back to the old target.
|
||||
if (_loadingSessionId !== sid) {
|
||||
if (!_isCurrentLoad()) {
|
||||
_rearmActiveSessionStream();
|
||||
return;
|
||||
}
|
||||
if (_loadingSessionId === sid) _loadingSessionId = null;
|
||||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||||
return loadSession(sid,{...opts,skipProfileResolve:true,force:true});
|
||||
}catch(switchErr){
|
||||
e=switchErr;
|
||||
@@ -1562,7 +1569,7 @@ async function loadSession(sid){
|
||||
// for the session the user actually navigated to. If we no longer own the
|
||||
// load, re-arm the active session's stream and bail before any DOM mutation
|
||||
// or self-heal.
|
||||
if (_loadingSessionId !== sid) {
|
||||
if (!_isCurrentLoad()) {
|
||||
_rearmActiveSessionStream();
|
||||
return;
|
||||
}
|
||||
@@ -1583,7 +1590,7 @@ async function loadSession(sid){
|
||||
if(!currentSid || currentSid===sid){
|
||||
try{ localStorage.removeItem('hermes-webui-session'); }catch(_){ }
|
||||
try{ history.replaceState(null,'',_appRootPath()); }catch(_){ }
|
||||
if (_loadingSessionId === sid) _loadingSessionId = null;
|
||||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||||
if(!currentSid){
|
||||
throw e;
|
||||
}
|
||||
@@ -1607,7 +1614,7 @@ async function loadSession(sid){
|
||||
// NOT restart — doing so would spin the SSE reconnect loop against a dead
|
||||
// session_id.
|
||||
const _selfHealedCurrent = (e.status===404) && (currentSid===sid);
|
||||
if (_loadingSessionId === sid) _loadingSessionId = null;
|
||||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||||
// The session stream was stopped unconditionally at the top of this load
|
||||
// (mirroring stopApprovalPolling). On the happy path it's restarted ~120
|
||||
// lines below, but this failure exit never reaches that point — leaving
|
||||
@@ -1636,14 +1643,14 @@ async function loadSession(sid){
|
||||
// send users to empty state after re-login (#4028 follow-up).
|
||||
if (!data) {
|
||||
_clearSameSessionForceReloadHint(sid);
|
||||
if (_loadingSessionId === sid) _loadingSessionId = null;
|
||||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||||
// #2971: re-arm the still-displayed session's stream (defensive — harmless
|
||||
// if the 401 redirect is already tearing the page down). Idempotent.
|
||||
_rearmActiveSessionStream();
|
||||
return;
|
||||
}
|
||||
// Stale response? A newer loadSession() call has already started (#1060).
|
||||
if (_loadingSessionId !== sid) {
|
||||
if (!_isCurrentLoad()) {
|
||||
// #2971: a newer in-flight load owns the final stream arming, but until it
|
||||
// assigns S.session and reaches startSessionStream() the currently-shown
|
||||
// session must not be left stream-dead by our top-of-function teardown.
|
||||
@@ -1660,7 +1667,7 @@ async function loadSession(sid){
|
||||
// cross-profile continuation can't poison restore state with an unusable id.
|
||||
const continuationSid=(data.session&&data.session.continuation_session_id)||'';
|
||||
if(continuationSid&&continuationSid!==sid&&!opts.skipContinuationResolve){
|
||||
_loadingSessionId=null;
|
||||
if (_isCurrentLoad()) _loadingSessionId=null;
|
||||
return loadSession(continuationSid,{...opts,skipLineageResolve:true,skipContinuationResolve:true,force:true});
|
||||
}
|
||||
S.session=data.session;
|
||||
@@ -1802,10 +1809,18 @@ async function loadSession(sid){
|
||||
// this session's INFLIGHT snapshot, not leave prior-session rows in place.
|
||||
if(typeof clearLiveToolCards==='function') clearLiveToolCards();
|
||||
try {
|
||||
await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded});
|
||||
await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded, loadGeneration:_loadGeneration});
|
||||
} catch(e) {
|
||||
if (!_isCurrentLoad()) {
|
||||
_rearmActiveSessionStream();
|
||||
return;
|
||||
}
|
||||
S.messages=inflightMessages;
|
||||
}
|
||||
if (!_isCurrentLoad()) {
|
||||
_rearmActiveSessionStream();
|
||||
return;
|
||||
}
|
||||
const liveTailPrepared=_prepareRunningLiveTail(S.messages,inflightMessages);
|
||||
if(liveTailPrepared){
|
||||
S.messages=_dropCurrentTurnAssistantMessages(S.messages);
|
||||
@@ -1838,7 +1853,7 @@ async function loadSession(sid){
|
||||
let didReconnect=false;
|
||||
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
|
||||
INFLIGHT[sid].reattach=false;
|
||||
if (_loadingSessionId !== sid) return;
|
||||
if (!_isCurrentLoad()) return;
|
||||
didReconnect=true;
|
||||
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||
}
|
||||
@@ -1900,8 +1915,12 @@ async function loadSession(sid){
|
||||
// "messages already populated" early-return inside _ensureMessagesLoaded
|
||||
// does NOT skip the swap to the new transcript.
|
||||
try {
|
||||
await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded});
|
||||
await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded, loadGeneration:_loadGeneration});
|
||||
} catch (e) {
|
||||
if (!_isCurrentLoad()) {
|
||||
_rearmActiveSessionStream();
|
||||
return;
|
||||
}
|
||||
// Network errors, server failures, or SSE drops (Chrome error codes 4/5)
|
||||
// can cause _ensureMessagesLoaded to throw. Without a try/catch here the
|
||||
// "Loading conversation..." div injected at the top of loadSession would
|
||||
@@ -1911,11 +1930,11 @@ async function loadSession(sid){
|
||||
_msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Failed to load messages. Try switching sessions or refreshing.</div>';
|
||||
}
|
||||
if (typeof showToast === 'function') showToast('Failed to load conversation messages', 3000, 'error');
|
||||
if (_loadingSessionId === sid) _loadingSessionId = null;
|
||||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||||
return;
|
||||
}
|
||||
// Stale? A newer loadSession() call has already started (#1060).
|
||||
if (_loadingSessionId !== sid) return;
|
||||
if (!_isCurrentLoad()) return;
|
||||
|
||||
// Restore any queued message that survived page refresh or tab restore.
|
||||
if(typeof queueSessionMessage==='function'){
|
||||
@@ -2032,7 +2051,7 @@ async function loadSession(sid){
|
||||
}
|
||||
|
||||
// Clear the in-flight session marker now that this load has completed (#1060).
|
||||
if (_loadingSessionId === sid) _loadingSessionId = null;
|
||||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||||
|
||||
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
|
||||
|
||||
@@ -2737,6 +2756,9 @@ async function _ensureMessagesLoaded(sid, opts) {
|
||||
// _ensureMessagesLoaded to fetch and SWAP the new transcript into
|
||||
// S.messages in a single frame.
|
||||
opts = opts || {};
|
||||
const _loadGeneration = Number.isFinite(opts.loadGeneration) ? Number(opts.loadGeneration) : null;
|
||||
const _ownsLoad = () => _loadingSessionId === sid && (_loadGeneration === null || _loadSessionGeneration === _loadGeneration);
|
||||
if (!_ownsLoad()) return;
|
||||
// Already have messages? (e.g. from INFLIGHT restore path, already set)
|
||||
if (!opts.force && S.messages && S.messages.length > 0 && S.messages[0] && S.messages[0].role) {
|
||||
_clearSameSessionForceReloadHint(sid);
|
||||
@@ -2756,8 +2778,9 @@ async function _ensureMessagesLoaded(sid, opts) {
|
||||
{timeoutMs:120000}
|
||||
);
|
||||
} finally {
|
||||
_clearSameSessionForceReloadHint(sid);
|
||||
if (_ownsLoad()) _clearSameSessionForceReloadHint(sid);
|
||||
}
|
||||
if (!_ownsLoad()) return;
|
||||
// Guard: api() may have redirected (401) and returned undefined.
|
||||
if (!data || !data.session) return;
|
||||
_messagesTruncated = !!data.session._messages_truncated;
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
"""Regression tests for cross-session transcript isolation in loadSession.
|
||||
|
||||
The checks lock behavior at both levels:
|
||||
|
||||
1) Structural guard checks in ``loadSession()`` and ``_ensureMessagesLoaded()``
|
||||
around ownership tokens and catch-path mutations.
|
||||
2) Runtime ordering/catch coverage using an executable Node harness to reproduce
|
||||
old->new load overlap and stale rejected continuation behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SESSIONS_SRC = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _extract_function(source: str, name: str) -> str:
|
||||
"""Return the full function source for ``name`` from a single js file.
|
||||
|
||||
Brace-depth tracking handles nested blocks and avoids fragile substring
|
||||
matching in the large, hand-formatted source file.
|
||||
"""
|
||||
marker = f"async function {name}("
|
||||
start = source.find(marker)
|
||||
if start < 0:
|
||||
marker = f"function {name}("
|
||||
start = source.find(marker)
|
||||
assert start >= 0, f"{name} not found in sessions.js"
|
||||
|
||||
brace_start = source.find("{", start)
|
||||
assert brace_start >= 0, f"function {name} is missing '{{'"
|
||||
|
||||
depth = 0
|
||||
in_string = None
|
||||
escaped = False
|
||||
in_line_comment = False
|
||||
in_block_comment = False
|
||||
|
||||
for index in range(brace_start, len(source)):
|
||||
ch = source[index]
|
||||
nxt = source[index + 1] if index + 1 < len(source) else ""
|
||||
|
||||
if in_line_comment:
|
||||
if ch == "\n":
|
||||
in_line_comment = False
|
||||
continue
|
||||
if in_block_comment:
|
||||
if ch == "*" and nxt == "/":
|
||||
in_block_comment = False
|
||||
continue
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == in_string:
|
||||
in_string = None
|
||||
continue
|
||||
|
||||
if ch == "/" and nxt == "/":
|
||||
in_line_comment = True
|
||||
continue
|
||||
if ch == "/" and nxt == "*":
|
||||
in_block_comment = True
|
||||
continue
|
||||
if ch in ('\'', '"', "`"):
|
||||
in_string = ch
|
||||
continue
|
||||
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start : index + 1]
|
||||
|
||||
raise AssertionError(f"Could not extract function {name}")
|
||||
|
||||
|
||||
LOAD_SESSION_SRC = _extract_function(SESSIONS_SRC, "loadSession")
|
||||
ENSURE_MESSAGES_LOADED_SRC = _extract_function(SESSIONS_SRC, "_ensureMessagesLoaded")
|
||||
|
||||
|
||||
def _normalise_ws(s: str) -> str:
|
||||
return re.sub(r"\s+", "", s)
|
||||
|
||||
|
||||
def test_loadsession_has_generation_token_and_forwards_to_ensure_messages_loaded():
|
||||
body = LOAD_SESSION_SRC
|
||||
assert "_loadSessionGeneration" in body, (
|
||||
"loadSession() must use a global generation counter so superseded loads "
|
||||
"can be rejected by continuation ownership checks"
|
||||
)
|
||||
assert "const _loadGeneration = ++_loadSessionGeneration" in body, (
|
||||
"loadSession() must increment and capture per-call generation"
|
||||
)
|
||||
assert "const _isCurrentLoad = () => _loadingSessionId === sid && _loadSessionGeneration === _loadGeneration" in body
|
||||
assert "loadGeneration:_loadGeneration" in body, (
|
||||
"loadSession() must thread generation into _ensureMessagesLoaded()"
|
||||
)
|
||||
# Guard each await/catch branch so stale continuation cannot mutate shared pane state.
|
||||
# Two calls exist in this function: INFLIGHT and idle branches.
|
||||
norm = _normalise_ws(body)
|
||||
assert norm.count("if(!_isCurrentLoad())") >= 6, (
|
||||
"loadSession() should check ownership in multiple await/catch paths, "
|
||||
"including stale _ensureMessagesLoaded catch branches"
|
||||
)
|
||||
ensure_call = _normalise_ws("await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded, loadGeneration:_loadGeneration});")
|
||||
assert ensure_call in norm, (
|
||||
"loadSession() must pass generation into _ensureMessagesLoaded() for stale-owner checks"
|
||||
)
|
||||
assert (
|
||||
"showToast('Failed to load session" in LOAD_SESSION_SRC
|
||||
or "showToast('Failed to load conversation messages" in LOAD_SESSION_SRC
|
||||
), "loadSession() should preserve toast-based failure paths"
|
||||
|
||||
|
||||
def test_ensure_messages_loaded_ownership_guard_pre_and_post_await():
|
||||
body = ENSURE_MESSAGES_LOADED_SRC
|
||||
assert "_loadSessionGeneration" in body, "_ensureMessagesLoaded should read generation"
|
||||
assert "const _loadGeneration = Number.isFinite(opts.loadGeneration) ? Number(opts.loadGeneration) : null" in body
|
||||
norm = _normalise_ws(body)
|
||||
assert (
|
||||
"_loadGeneration===null||_loadSessionGeneration===_loadGeneration" in norm
|
||||
), "_ensureMessagesLoaded must compare generation token"
|
||||
assert norm.count("if(!_ownsLoad())return;") >= 2, (
|
||||
"_ensureMessagesLoaded needs pre/post await ownership guards"
|
||||
)
|
||||
assert "_loadGeneration" in body, "_ensureMessagesLoaded should read generation from opts"
|
||||
|
||||
|
||||
_NODE_SCRIPT_TEMPLATE = r'''
|
||||
function makeHarness() {
|
||||
const apiCalls = [];
|
||||
const queue = [];
|
||||
const pending = [];
|
||||
function enqueue(url, value, mode="resolve") {
|
||||
const defer = { url: String(url), value, mode, resolved: false };
|
||||
defer.promise = new Promise((resolve, reject) => {
|
||||
defer._resolve = resolve;
|
||||
defer._reject = reject;
|
||||
});
|
||||
queue.push(defer);
|
||||
return defer;
|
||||
}
|
||||
|
||||
async function api(url) {
|
||||
apiCalls.push(String(url));
|
||||
const entry = queue.shift();
|
||||
if (!entry) {
|
||||
throw new Error('Unexpected API call: ' + String(url));
|
||||
}
|
||||
if (entry.url !== String(url)) {
|
||||
throw new Error('API order mismatch, expected ' + entry.url + ', got ' + String(url));
|
||||
}
|
||||
pending.push(entry);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
return { api, apiCalls, enqueue, pending };
|
||||
}
|
||||
|
||||
function snapshotState() {
|
||||
return {
|
||||
sid: S.session && S.session.session_id,
|
||||
messages: Array.isArray(S.messages) ? S.messages.map((m) => (m && m.role ? String(m.content || '') : null)).filter(Boolean) : [],
|
||||
toolCalls: Array.isArray(S.toolCalls) ? S.toolCalls.slice() : [],
|
||||
truncated: _messagesTruncated,
|
||||
oldestIdx: _oldestIdx,
|
||||
loadingSid: _loadingSessionId,
|
||||
loadingGeneration: _loadSessionGeneration,
|
||||
msgInner: _msgInner.innerHTML,
|
||||
toastCalls: toastCalls.slice(),
|
||||
rearmCalls,
|
||||
apiCalls: apiHost.apiCalls.slice(),
|
||||
clearHintCalls,
|
||||
visibleCacheClears,
|
||||
liveCardClears,
|
||||
toolSyncCalls,
|
||||
};
|
||||
}
|
||||
|
||||
function createEnvironment() {
|
||||
globalThis.INFLIGHT = {};
|
||||
globalThis.S = {
|
||||
session: { session_id: 'sid-init', message_count: 0 },
|
||||
messages: [{ role: 'assistant', content: 'seed' }],
|
||||
toolCalls: [],
|
||||
pendingFiles: [],
|
||||
busy: false,
|
||||
activeStreamId: null,
|
||||
};
|
||||
globalThis._loadingSessionId = null;
|
||||
globalThis._loadingOlder = false;
|
||||
globalThis._loadSessionGeneration = 0;
|
||||
globalThis._pendingCarryForwardSnapshot = null;
|
||||
globalThis._messagesTruncated = false;
|
||||
globalThis._oldestIdx = 0;
|
||||
globalThis._messageRenderWindowSize = 0;
|
||||
globalThis._messageReloadLimitForSession = () => 2;
|
||||
globalThis._currentMessageRenderWindowSize = () => 1;
|
||||
globalThis._messageRenderableMessageCount = () => 2;
|
||||
|
||||
globalThis._rearmActiveSessionStream = () => { rearmCalls += 1; };
|
||||
globalThis.stopApprovalPolling = () => {};
|
||||
globalThis.hideApprovalCard = () => {};
|
||||
globalThis.stopSessionStream = () => {};
|
||||
globalThis._yoloEnabled = false;
|
||||
globalThis._updateYoloPill = () => {};
|
||||
globalThis.stopClarifyPolling = () => {};
|
||||
globalThis.hideClarifyCard = () => {};
|
||||
globalThis._saveComposerDraftNow = () => Promise.resolve();
|
||||
globalThis._sessionProfileMismatchFromError = () => null;
|
||||
globalThis._switchProfileForSessionLoad = async () => {};
|
||||
globalThis._clearSameSessionForceReloadHint = () => { clearHintCalls += 1; };
|
||||
globalThis._clearStuckSessionOnBoot = () => {};
|
||||
globalThis._setSessionViewedCount = () => {};
|
||||
globalThis.scheduleTodosRefresh = () => {};
|
||||
globalThis.startSessionStream = () => {};
|
||||
globalThis.syncTopbar = () => {};
|
||||
globalThis._captureSameSessionForceReloadHint = () => {};
|
||||
globalThis._resolveSessionModelForDisplaySoon = () => {};
|
||||
globalThis._setSessionCompletionUnread = () => {};
|
||||
globalThis._clearSessionCompletionUnread = () => {};
|
||||
globalThis._setActiveSessionUrl = () => {};
|
||||
globalThis._deferWorkspaceRefreshForSession = () => {};
|
||||
globalThis._sessionListRender = () => {};
|
||||
globalThis._setSessionToolset = () => {};
|
||||
globalThis._applyPendingSessionModelForSession = () => {};
|
||||
globalThis.populateModelDropdown = () => {};
|
||||
globalThis._deferSessionSideEffect = (sid, fn) => Promise.resolve(fn());
|
||||
globalThis._hydrateTodosFromSession = () => {};
|
||||
globalThis._resolveLineage = () => {};
|
||||
globalThis._clearPendingSelections = () => {};
|
||||
globalThis._clearQueueCardDisplay = () => {};
|
||||
globalThis._syncTodosForSession = () => {};
|
||||
globalThis._clearAllTodosFromSession = () => {};
|
||||
globalThis._setSessionModelFromSession = () => {};
|
||||
globalThis._clearEmptyComposerModelOverride = () => {};
|
||||
globalThis._deferSessionProfileSwitch = () => {};
|
||||
globalThis._resolveSessionSideEffect = () => {};
|
||||
|
||||
|
||||
globalThis._clearMessageCache = () => {};
|
||||
globalThis._syncToolCallsForLoadedMessages = (msgs, toolCalls) => {
|
||||
toolSyncCalls += 1;
|
||||
S.toolCalls = [];
|
||||
if (Array.isArray(toolCalls)) {
|
||||
S.toolCalls = toolCalls.map((tc) => ({ ...tc, done: true }));
|
||||
}
|
||||
};
|
||||
globalThis.clearVisibleMessageRowCache = () => { visibleCacheClears += 1; };
|
||||
globalThis.clearLiveToolCards = () => { liveCardClears += 1; };
|
||||
|
||||
globalThis._syncCtxIndicator = () => {};
|
||||
globalThis._renderPendingPromptsForActiveSession = () => {};
|
||||
globalThis._restoreComposerDraft = () => {};
|
||||
globalThis.renderSessionArtifacts = () => {};
|
||||
globalThis.renderMessages = () => {};
|
||||
globalThis._checkAndShowHandoffHint = () => {};
|
||||
globalThis._hideHandoffHint = () => {};
|
||||
globalThis._isMessagingSession = () => true;
|
||||
globalThis._clearDeferredActiveSessionExternalRefresh = () => {};
|
||||
|
||||
globalThis.setStatus = () => {};
|
||||
globalThis.setComposerStatus = () => {};
|
||||
globalThis.setBusy = () => {};
|
||||
globalThis.updateSendBtn = () => {};
|
||||
globalThis.updateQueueBadge = () => {};
|
||||
globalThis.startApprovalPolling = () => {};
|
||||
globalThis.startClarifyPolling = () => {};
|
||||
globalThis._fetchYoloState = () => {};
|
||||
|
||||
globalThis._resolveSessionIdFromSidebarLineage = (sid) => sid;
|
||||
globalThis._resolveSessionLineage = (sid) => sid;
|
||||
|
||||
globalThis._messageReloadLimitForSession = () => 2;
|
||||
|
||||
globalThis._msgInner = { innerHTML: 'INIT_LOADING' };
|
||||
const _msgInput = { value: '' };
|
||||
globalThis.$ = (id) => {
|
||||
if (id === 'msgInner') return _msgInner;
|
||||
if (id === 'msg') return _msgInput;
|
||||
return null;
|
||||
};
|
||||
|
||||
globalThis.autoResize = () => {};
|
||||
globalThis.showToast = (msg) => {
|
||||
toastCalls.push(String(msg));
|
||||
};
|
||||
|
||||
globalThis.window = {};
|
||||
globalThis.history = { replaceState: () => {} };
|
||||
globalThis.localStorage = {
|
||||
removeItem: () => {},
|
||||
setItem: () => {},
|
||||
getItem: () => null,
|
||||
};
|
||||
globalThis._appRootPath = () => '/';
|
||||
|
||||
rearmCalls = 0;
|
||||
clearHintCalls = 0;
|
||||
visibleCacheClears = 0;
|
||||
liveCardClears = 0;
|
||||
toolSyncCalls = 0;
|
||||
toastCalls = [];
|
||||
}
|
||||
|
||||
let rearmCalls = 0;
|
||||
let clearHintCalls = 0;
|
||||
let visibleCacheClears = 0;
|
||||
let liveCardClears = 0;
|
||||
let toolSyncCalls = 0;
|
||||
let toastCalls = [];
|
||||
|
||||
// Source under test
|
||||
__LOAD_SESSION_SRC__
|
||||
__ENSURE_MESSAGES_LOADED_SRC__
|
||||
|
||||
async function waitForQueued(apiHost, url) {
|
||||
const target = String(url);
|
||||
while (!apiHost.pending.some((entry) => entry.url === target)) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const API_BEACON_META = {
|
||||
session: {
|
||||
session_id: 'sid-beacon',
|
||||
message_count: 12,
|
||||
active_stream_id: null,
|
||||
resolve_model: 'qwen/qwq-32b-instruct',
|
||||
},
|
||||
};
|
||||
|
||||
const API_BEACON_MSGS = {
|
||||
session: {
|
||||
session_id: 'sid-beacon',
|
||||
_messages_truncated: true,
|
||||
_messages_offset: 7,
|
||||
messages: [{ role: 'assistant', content: 'stale-beacon-transcript' }],
|
||||
message_count: 12,
|
||||
tool_calls: [{ name: 'tool-beacon-stale' }],
|
||||
},
|
||||
};
|
||||
|
||||
const API_BEACON_INFLIGHT_STATE = {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'beacon-inflight-tail',
|
||||
_live: true,
|
||||
},
|
||||
],
|
||||
uploaded: [],
|
||||
toolCalls: [{ name: 'tool-beacon-inflight' }],
|
||||
};
|
||||
|
||||
const API_ATLAS_META = {
|
||||
session: {
|
||||
session_id: 'sid-atlas',
|
||||
message_count: 21,
|
||||
active_stream_id: null,
|
||||
resolve_model: 'qwen/qwq-32b-instruct',
|
||||
},
|
||||
};
|
||||
|
||||
const API_ATLAS_MSGS = {
|
||||
session: {
|
||||
session_id: 'sid-atlas',
|
||||
_messages_truncated: false,
|
||||
_messages_offset: 98,
|
||||
messages: [{ role: 'assistant', content: 'new-active-transcript' }],
|
||||
message_count: 21,
|
||||
tool_calls: [{ name: 'tool-atlas' }],
|
||||
},
|
||||
};
|
||||
|
||||
const API_ATLAS_RELOAD_META = {
|
||||
session: {
|
||||
session_id: 'sid-atlas',
|
||||
message_count: 31,
|
||||
active_stream_id: null,
|
||||
resolve_model: 'qwen/qwq-32b-instruct',
|
||||
},
|
||||
};
|
||||
|
||||
const API_ATLAS_RELOAD_MSGS = {
|
||||
session: {
|
||||
session_id: 'sid-atlas',
|
||||
_messages_truncated: true,
|
||||
_messages_offset: 33,
|
||||
messages: [{ role: 'assistant', content: 'reloaded-active-transcript' }],
|
||||
message_count: 31,
|
||||
tool_calls: [{ name: 'tool-atlas-new' }],
|
||||
},
|
||||
};
|
||||
|
||||
function buildMessageUrl(sid, mode, suffix='') {
|
||||
const base = `/api/session?session_id=${encodeURIComponent(sid)}&messages=${mode}&resolve_model=0`;
|
||||
if (mode === 0) return base;
|
||||
return `${base}&msg_limit=${_messageReloadLimitForSession()}&expand_renderable=1${suffix}`;
|
||||
}
|
||||
|
||||
function makeCrossSessionCalls(apiHost) {
|
||||
return {
|
||||
beaconMeta: apiHost.enqueue(buildMessageUrl('sid-beacon', 0)),
|
||||
beaconMsgs: apiHost.enqueue(buildMessageUrl('sid-beacon', 1)),
|
||||
atlasMeta: apiHost.enqueue(buildMessageUrl('sid-atlas', 0)),
|
||||
atlasMsgs: apiHost.enqueue(buildMessageUrl('sid-atlas', 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function runCrossSessionOrderingBase({seedBeaconInflight, resolveBeaconMsgsBeforeAtlasMeta}) {
|
||||
createEnvironment();
|
||||
if (seedBeaconInflight) {
|
||||
INFLIGHT['sid-beacon'] = JSON.parse(JSON.stringify(API_BEACON_INFLIGHT_STATE));
|
||||
}
|
||||
|
||||
const apiHost = makeHarness();
|
||||
globalThis.apiHost = apiHost;
|
||||
globalThis.api = apiHost.api;
|
||||
|
||||
const calls = makeCrossSessionCalls(apiHost);
|
||||
|
||||
const first = loadSession('sid-beacon', { force: true });
|
||||
return (async () => {
|
||||
await waitForQueued(apiHost, calls.beaconMeta.url);
|
||||
calls.beaconMeta._resolve(API_BEACON_META);
|
||||
|
||||
await waitForQueued(apiHost, calls.beaconMsgs.url);
|
||||
const second = loadSession('sid-atlas', { force: true });
|
||||
await waitForQueued(apiHost, calls.atlasMeta.url);
|
||||
|
||||
if (resolveBeaconMsgsBeforeAtlasMeta) {
|
||||
calls.beaconMsgs._resolve(API_BEACON_MSGS);
|
||||
// Wait for the stale first load continuation to process so we can continue the
|
||||
// Atlas path from a clearly stale state.
|
||||
await first;
|
||||
calls.atlasMeta._resolve(API_ATLAS_META);
|
||||
await waitForQueued(apiHost, calls.atlasMsgs.url);
|
||||
} else {
|
||||
calls.atlasMeta._resolve(API_ATLAS_META);
|
||||
await waitForQueued(apiHost, calls.atlasMsgs.url);
|
||||
calls.beaconMsgs._resolve(API_BEACON_MSGS);
|
||||
}
|
||||
|
||||
calls.atlasMsgs._resolve(API_ATLAS_MSGS);
|
||||
await Promise.all([first, second]);
|
||||
|
||||
return {
|
||||
finalSid: S.session && S.session.session_id,
|
||||
messages: snapshotState().messages,
|
||||
toolCalls: snapshotState().toolCalls,
|
||||
truncated: snapshotState().truncated,
|
||||
oldestIdx: snapshotState().oldestIdx,
|
||||
msgInner: snapshotState().msgInner,
|
||||
toastCalls: snapshotState().toastCalls,
|
||||
apiCalls: snapshotState().apiCalls,
|
||||
loadingSid: snapshotState().loadingSid,
|
||||
loadingGeneration: snapshotState().loadingGeneration,
|
||||
rearmCalls: snapshotState().rearmCalls,
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
async function runCrossSessionOrdering() {
|
||||
return {
|
||||
scenario: 'cross-session-ordering',
|
||||
...(await runCrossSessionOrderingBase({ seedBeaconInflight: true, resolveBeaconMsgsBeforeAtlasMeta: false })),
|
||||
};
|
||||
}
|
||||
|
||||
async function runObservedIdleCrossSessionOrdering() {
|
||||
return {
|
||||
scenario: 'observed-idle-cross-session-ordering',
|
||||
...(await runCrossSessionOrderingBase({ seedBeaconInflight: false, resolveBeaconMsgsBeforeAtlasMeta: true })),
|
||||
};
|
||||
}
|
||||
|
||||
async function runStaleRejectedIdleCatch() {
|
||||
createEnvironment();
|
||||
const apiHost = makeHarness();
|
||||
globalThis.apiHost = apiHost;
|
||||
globalThis.api = apiHost.api;
|
||||
|
||||
S.session = { session_id: 'sid-atlas', message_count: 0 };
|
||||
|
||||
const calls = {
|
||||
firstMeta: apiHost.enqueue(buildMessageUrl('sid-atlas', 0)),
|
||||
firstMsgs: apiHost.enqueue(buildMessageUrl('sid-atlas', 1)),
|
||||
secondMeta: apiHost.enqueue(buildMessageUrl('sid-atlas', 0)),
|
||||
secondMsgs: apiHost.enqueue(buildMessageUrl('sid-atlas', 1)),
|
||||
};
|
||||
|
||||
const first = loadSession('sid-atlas', { force: true });
|
||||
calls.firstMeta._resolve(API_ATLAS_META);
|
||||
|
||||
// Ensure the first load has entered the messages fetch and owns the pending API
|
||||
// call before the superseding same-session load begins.
|
||||
await waitForQueued(apiHost, calls.firstMsgs.url);
|
||||
|
||||
const second = loadSession('sid-atlas', { force: true });
|
||||
|
||||
// The stale first request rejects while the second newer request is in flight.
|
||||
calls.firstMsgs._reject(new Error('owner lost while load was in-flight'));
|
||||
calls.secondMeta._resolve(API_ATLAS_RELOAD_META);
|
||||
calls.secondMsgs._resolve(API_ATLAS_RELOAD_MSGS);
|
||||
|
||||
await Promise.all([first, second]);
|
||||
|
||||
return {
|
||||
scenario: 'stale-idle-catch',
|
||||
finalSid: S.session && S.session.session_id,
|
||||
messages: snapshotState().messages,
|
||||
toolCalls: snapshotState().toolCalls,
|
||||
truncated: snapshotState().truncated,
|
||||
oldestIdx: snapshotState().oldestIdx,
|
||||
msgInner: snapshotState().msgInner,
|
||||
toastCalls: snapshotState().toastCalls,
|
||||
apiCalls: snapshotState().apiCalls,
|
||||
loadingSid: snapshotState().loadingSid,
|
||||
loadingGeneration: snapshotState().loadingGeneration,
|
||||
rearmCalls: snapshotState().rearmCalls,
|
||||
};
|
||||
}
|
||||
|
||||
async function runAll() {
|
||||
return {
|
||||
crossSessionOrdering: await runCrossSessionOrdering(),
|
||||
observedIdleCrossSessionOrdering: await runObservedIdleCrossSessionOrdering(),
|
||||
staleIdleCatch: await runStaleRejectedIdleCatch(),
|
||||
};
|
||||
}
|
||||
|
||||
runAll()
|
||||
.then((r) => console.log(JSON.stringify(r)))
|
||||
.catch((err) => {
|
||||
console.error('NODE_ERROR', err && err.stack || err);
|
||||
process.exit(1);
|
||||
});
|
||||
'''
|
||||
|
||||
|
||||
def _run_node(script: str) -> dict:
|
||||
assert NODE is not None, "node is required"
|
||||
completed = subprocess.run(
|
||||
[NODE, "--input-type=module", "-e", script],
|
||||
cwd=str(REPO),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr or completed.stdout
|
||||
output_lines = [line.strip() for line in completed.stdout.splitlines() if line.strip()]
|
||||
assert output_lines, f"node produced no parseable output\nstdout={completed.stdout}\nstderr={completed.stderr}"
|
||||
return json.loads(output_lines[-1])
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_loadsession_cross_session_ordering_and_stale_reject_behavior():
|
||||
script = _NODE_SCRIPT_TEMPLATE.replace(
|
||||
"__LOAD_SESSION_SRC__", LOAD_SESSION_SRC
|
||||
).replace(
|
||||
"__ENSURE_MESSAGES_LOADED_SRC__", ENSURE_MESSAGES_LOADED_SRC
|
||||
)
|
||||
body = _run_node(script)
|
||||
|
||||
cross = body["crossSessionOrdering"]
|
||||
stale = body["staleIdleCatch"]
|
||||
observed = body["observedIdleCrossSessionOrdering"]
|
||||
|
||||
def _assert_atlas_wins(session_result, *, label):
|
||||
assert session_result["finalSid"] == "sid-atlas", f"{label}: stale overlap should end on Atlas session"
|
||||
assert session_result["messages"] == ["new-active-transcript"], (
|
||||
f"{label}: stale Beacon transcript must not replace Atlas transcript"
|
||||
)
|
||||
assert session_result["toolCalls"] == [{"name": "tool-atlas", "done": True}], (
|
||||
f"{label}: Atlas tool summary must apply on fresh load"
|
||||
)
|
||||
assert session_result["truncated"] is False and session_result["oldestIdx"] == 98, (
|
||||
f"{label}: Atlas metadata should remain the active state"
|
||||
)
|
||||
|
||||
# 1) Cross-session ordering: old (Beacon) loads first, but user advances to Atlas.
|
||||
assert cross["apiCalls"][0] == "/api/session?session_id=sid-beacon&messages=0&resolve_model=0", (
|
||||
"first API call should target old session's metadata"
|
||||
)
|
||||
assert cross["apiCalls"][1] == "/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", (
|
||||
"beacon transcript request should queue before atlas metadata resolves"
|
||||
)
|
||||
assert cross["apiCalls"][2] == "/api/session?session_id=sid-atlas&messages=0&resolve_model=0", (
|
||||
"second API call should target atlas metadata while stale beacon messages are in flight"
|
||||
)
|
||||
assert cross["apiCalls"][3] == "/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", (
|
||||
"atlas should still fetch a transcript while beacon was stale"
|
||||
)
|
||||
assert cross["apiCalls"].count("/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1") == 1, (
|
||||
"stale overlap should still issue the Beacon transcript call, but it must not win"
|
||||
)
|
||||
_assert_atlas_wins(cross, label="cross-session-ordering")
|
||||
|
||||
# 2) Observed idle-path race with no INFLIGHT: stale Beacon transcript returns
|
||||
# before Atlas metadata, but ownership guard must still force Atlas fetch+swap.
|
||||
assert observed["apiCalls"][0] == "/api/session?session_id=sid-beacon&messages=0&resolve_model=0", (
|
||||
"idle-path race should start from old Beacon metadata"
|
||||
)
|
||||
assert observed["apiCalls"][1] == "/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", (
|
||||
"Beacon transcript call should remain queued before Atlas metadata under observed race"
|
||||
)
|
||||
assert observed["apiCalls"][2] == "/api/session?session_id=sid-atlas&messages=0&resolve_model=0", (
|
||||
"Atlas metadata must start while Beacon continuation returns stale"
|
||||
)
|
||||
assert observed["apiCalls"][3] == "/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", (
|
||||
"Atlas transcript request must still issue despite stale Beacon return"
|
||||
)
|
||||
assert observed["apiCalls"].count("/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1") == 1, (
|
||||
"stale Beacon transcript should occur once in observed race"
|
||||
)
|
||||
assert observed["apiCalls"].count("/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1") == 1, (
|
||||
"Atlas transcript must be issued once once stale Beacon is processed first"
|
||||
)
|
||||
_assert_atlas_wins(observed, label="observed-idle-cross-session-ordering")
|
||||
assert observed["toastCalls"] == [], "stale Beacon return in idle-path race should not show toast"
|
||||
|
||||
# 3) Stale rejected idle-branch catch must be ownership-guarded and not mutate shared pane.
|
||||
assert stale["messages"] == ["reloaded-active-transcript"], "stale catch must not keep stale transcript"
|
||||
assert stale["toolCalls"] == [{"name": "tool-atlas-new", "done": True}], "stale catch must not overwrite tool state"
|
||||
assert stale["truncated"] is True and stale["oldestIdx"] == 33, "active owner should install latest metadata"
|
||||
assert stale["msgInner"] == "INIT_LOADING", (
|
||||
"stale reject from superseded load must not write failure placeholder"
|
||||
)
|
||||
assert stale["toastCalls"] == [], "stale reject must not surface toast for superseded load"
|
||||
assert stale["apiCalls"].count(
|
||||
"/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1"
|
||||
) == 2, "both old and active loads should have attempted message fetch"
|
||||
|
||||
assert cross["loadingSid"] is None, "load marker should be cleared after successful completion"
|
||||
assert stale["loadingSid"] is None, "load marker should be cleared after stale reject + re-owner completion"
|
||||
@@ -151,21 +151,27 @@ def test_load_session_reattach_path_uses_attach_live_stream_for_running_sessions
|
||||
|
||||
|
||||
def test_load_session_same_sid_noop_does_not_mask_pending_switch_back():
|
||||
"""Clicking back to the prior session during a pending switch must reload it.
|
||||
"""Clicking back to the prior session during a pending switch must still
|
||||
honor ownership.
|
||||
|
||||
loadSession() clears S.messages before the metadata fetch for the target
|
||||
session returns. During that small window S.session still points at the
|
||||
previous session. A fast click back to that previous sid used to hit the
|
||||
same-session no-op guard and leave the pane empty/Loading forever.
|
||||
previous session. A fast click back to that prior sid used to hit a hard
|
||||
same-session no-op and leave the pane empty/Loading forever.
|
||||
|
||||
The no-op guard now allows a same-session reload only when either no other
|
||||
load is in-flight, or the in-flight sid is the same sid (authoritative
|
||||
ownership). If a different sid is loading, the guard must not short-circuit
|
||||
so pending switch-back keeps progressing.
|
||||
"""
|
||||
body = _function_body(SESSIONS_JS, "loadSession")
|
||||
compact = re.sub(r"\s+", "", body)
|
||||
guard = "if(currentSid===sid&&!forceReload&&!_loadingSessionId)return;"
|
||||
guard = "if(currentSid===sid&&!forceReload&&(!_loadingSessionId||_loadingSessionId===sid))return;"
|
||||
assert guard in compact, (
|
||||
"same-session no-op must be disabled while another loadSession() call "
|
||||
"is in flight, otherwise switching away and immediately back can keep "
|
||||
"the previous session's cleared transcript"
|
||||
"same-session no-op must be owned by the current load target: "
|
||||
"another in-flight sid must not suppress a pending switch-back"
|
||||
)
|
||||
assert "_loadingSessionId===sid" in guard
|
||||
assert compact.find(guard) < compact.find("_loadingSessionId=sid;")
|
||||
|
||||
|
||||
|
||||
@@ -138,6 +138,70 @@ def test_openrouter_group_uses_live_fetch_when_available(monkeypatch):
|
||||
"free pricing model must surface even without :free suffix"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pricing", "case_name"),
|
||||
[
|
||||
({}, "missing"),
|
||||
({"prompt": "0"}, "completion missing"),
|
||||
({"completion": "0"}, "prompt missing"),
|
||||
({"prompt": None, "completion": None}, "null"),
|
||||
({"prompt": "n/a", "completion": "0"}, "malformed"),
|
||||
],
|
||||
)
|
||||
def test_openrouter_free_tier_pricing_fails_closed(
|
||||
monkeypatch,
|
||||
pricing,
|
||||
case_name,
|
||||
):
|
||||
"""Unknown pricing must not be presented as free without a :free ID."""
|
||||
candidate_id = f"vendor/{case_name.replace(' ', '-')}-pricing"
|
||||
explicit_free_id = "vendor/explicit-free:free"
|
||||
fake_payload = _make_or_payload(
|
||||
{"id": candidate_id, "name": case_name, "pricing": pricing},
|
||||
{"id": explicit_free_id, "name": "Explicit Free", "pricing": pricing},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
urllib.request,
|
||||
"urlopen",
|
||||
lambda *_args, **_kwargs: _FakeResponse(fake_payload),
|
||||
)
|
||||
|
||||
# hermes_cli (the agent package) is an optional dependency and is not
|
||||
# installed in the WebUI CI test environment. Force its live-fetch to
|
||||
# return empty when present so the fail-closed static path is exercised;
|
||||
# when absent, that static path is already the only one — mirror the
|
||||
# try/except guard the sibling tests in this file use.
|
||||
try:
|
||||
from hermes_cli import models as hermes_models
|
||||
monkeypatch.setattr(hermes_models, "fetch_openrouter_models", lambda **_kwargs: [])
|
||||
monkeypatch.setattr(hermes_models, "provider_model_ids", lambda *_args, **_kwargs: [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
grouped = _get_grouped_models()
|
||||
openrouter_group = next(
|
||||
(group for group in grouped if group.get("provider_id") == "openrouter"),
|
||||
None,
|
||||
)
|
||||
if openrouter_group is None:
|
||||
# Test-isolation pollution: when a sibling test (in this file or another
|
||||
# shard-adjacent module) mutates config.cfg away from the openrouter
|
||||
# active provider, get_available_models() omits the openrouter group
|
||||
# entirely. Skip rather than fail — same guard the sibling live-fetch
|
||||
# tests use — since the fail-closed contract under test only applies
|
||||
# when that branch actually runs.
|
||||
pytest.skip("openrouter group absent (likely test-isolation pollution from a sibling test)")
|
||||
model_ids = {
|
||||
model["id"]
|
||||
for bucket_name in ("models", "extra_models")
|
||||
for model in openrouter_group.get(bucket_name, [])
|
||||
}
|
||||
|
||||
assert candidate_id not in model_ids
|
||||
assert explicit_free_id in model_ids
|
||||
|
||||
|
||||
def test_openrouter_falls_back_to_static_when_live_fails(monkeypatch):
|
||||
"""If both hermes_cli.fetch and the direct urlopen raise, the picker
|
||||
must fall back to the hardcoded `_FALLBACK_MODELS` list — never empty."""
|
||||
|
||||
@@ -88,7 +88,7 @@ def test_stale_load_guard_present_before_self_heal():
|
||||
# loadSession catch block for the stale-load guard.
|
||||
heal_idx = js.index("_clearStuckSessionOnBoot(sid, currentSid);")
|
||||
block = js[heal_idx - 2400: heal_idx + 60]
|
||||
guard = "if (_loadingSessionId !== sid) {"
|
||||
guard = "if (!_isCurrentLoad()) {"
|
||||
assert guard in block, "stale-load guard missing from the loadSession catch block"
|
||||
# The guard must come BEFORE the self-heal call (so a superseded load can't clear).
|
||||
assert block.index(guard) < block.index("_clearStuckSessionOnBoot(sid, currentSid);"), \
|
||||
|
||||
@@ -78,6 +78,27 @@ def _refresh_block(compact: str) -> str:
|
||||
raise AssertionError("refreshActiveSessionIfExternallyUpdated braces did not balance")
|
||||
|
||||
|
||||
def _ensure_messages_loaded_block(compact: str) -> str:
|
||||
marker_named = "asyncfunction_ensureMessagesLoaded(sid,opts){"
|
||||
marker_arglist = "asyncfunction_ensureMessagesLoaded(sid){"
|
||||
start = compact.find(marker_named)
|
||||
if start == -1:
|
||||
start = compact.find(marker_arglist)
|
||||
assert start != -1, "_ensureMessagesLoaded definition not found"
|
||||
i = compact.find("{", start)
|
||||
assert i != -1, "_ensureMessagesLoaded opening brace not found"
|
||||
depth = 0
|
||||
for j in range(i, len(compact)):
|
||||
c = compact[j]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return compact[start : j + 1]
|
||||
raise AssertionError("_ensureMessagesLoaded braces did not balance")
|
||||
|
||||
|
||||
def test_keep_stale_until_loaded_flag_computed_in_loadsession():
|
||||
# The flag must be computed by AND-ing the caller's opts with
|
||||
# sameSessionForceReload — cross-session switches MUST keep clearing
|
||||
@@ -137,7 +158,7 @@ def test_ensure_messages_loaded_called_with_keep_stale_flag():
|
||||
# cannot skip the swap when stale messages are still in place.
|
||||
block = _load_session_block(_compact(SESSIONS_JS))
|
||||
# Both INFLIGHT and idle paths.
|
||||
assert block.count("await_ensureMessagesLoaded(sid,{force:_keepStaleUntilLoaded})") == 2
|
||||
assert block.count("await_ensureMessagesLoaded(sid,{force:_keepStaleUntilLoaded,loadGeneration:_loadGeneration})") == 2
|
||||
|
||||
|
||||
def test_ensure_messages_loaded_supports_force_override():
|
||||
@@ -150,14 +171,7 @@ def test_ensure_messages_loaded_supports_force_override():
|
||||
# greptile P2 r3393… on #5189) and the historical `arguments[1]` shape
|
||||
# (for callers preserving an existing public signature). The required
|
||||
# invariant is the EARLY-RETURN being gated on opts.force.
|
||||
marker_named = "asyncfunction_ensureMessagesLoaded(sid,opts){"
|
||||
marker_arglist = "asyncfunction_ensureMessagesLoaded(sid){"
|
||||
start = compact.find(marker_named)
|
||||
if start == -1:
|
||||
start = compact.find(marker_arglist)
|
||||
assert start != -1, "_ensureMessagesLoaded definition not found"
|
||||
# Take a generously-sized window for the early-return region.
|
||||
region = compact[start: start + 1000]
|
||||
region = _ensure_messages_loaded_block(compact)
|
||||
# Either explicit named param `opts` (preferred), or arguments[1] fallback,
|
||||
# both must coerce to an object so opts.force is safe to read.
|
||||
assert (
|
||||
|
||||
@@ -519,7 +519,7 @@ def test_completion_unread_clears_only_when_session_is_opened():
|
||||
assert load_idx != -1, "loadSession not found"
|
||||
load_block = SESSIONS_JS[load_idx:SESSIONS_JS.find("function _resolveSessionModelForDisplaySoon", load_idx)]
|
||||
|
||||
stale_guard_idx = load_block.find("if (_loadingSessionId !== sid) return;")
|
||||
stale_guard_idx = load_block.find("if (!_isCurrentLoad()) return;")
|
||||
clear_idx = load_block.find("_clearSessionCompletionUnread(S.session.session_id);")
|
||||
set_viewed_idx = load_block.find("_setSessionViewedCount(S.session.session_id")
|
||||
|
||||
|
||||
@@ -124,12 +124,12 @@ def test_pre_switch_draft_flush_rechecks_stale_loading_guard():
|
||||
"""The awaited draft-save in loadSession yields the event loop. On a rapid
|
||||
session switch (B then quickly C) the stale B continuation must bail out
|
||||
before the destructive state-clearing block, or it would wipe the
|
||||
freshly-loaded C state. The guard is `if (_loadingSessionId !== sid) return;`
|
||||
freshly-loaded C state. The guard is `if (!_isCurrentLoad()) return;`
|
||||
placed AFTER the awaited save and BEFORE the `S.messages = []` clear
|
||||
(Codex pre-release CORE catch, #3471)."""
|
||||
body = _load_session_clear_block()
|
||||
await_idx = body.find("await _saveComposerDraftNow(currentSid")
|
||||
guard_idx = body.find("if (_loadingSessionId !== sid) return;", await_idx)
|
||||
guard_idx = body.find("if (!_isCurrentLoad()) return;", await_idx)
|
||||
clear_idx = body.find("S.messages = [];", await_idx)
|
||||
assert await_idx != -1, "pre-switch awaited draft save not found"
|
||||
assert guard_idx != -1, "stale-loading guard missing after the awaited draft save"
|
||||
|
||||
@@ -588,7 +588,9 @@ def test_queue_card_cross_session_helper_used_only_for_session_change(cleanup_te
|
||||
assert "_clearQueueCardDisplay(currentSid);" in load_body[cross_start:cross_end], (
|
||||
"queue-card clear helper must be inside the cross-session branch"
|
||||
)
|
||||
same_session_idx = load_body.find("if(currentSid===sid && !forceReload && !_loadingSessionId) return;")
|
||||
same_session_idx = load_body.find(
|
||||
"if(currentSid===sid && !forceReload && (!_loadingSessionId || _loadingSessionId===sid)) return;"
|
||||
)
|
||||
assert same_session_idx >= 0
|
||||
assert same_session_idx < cross_start
|
||||
|
||||
|
||||
@@ -956,10 +956,11 @@ def test_load_session_rearms_stream_on_every_early_return():
|
||||
|
||||
# Specifically: the same-session no-op guard must be PRECEDED by a re-arm
|
||||
# so re-selecting a session whose stream a prior failed load killed revives
|
||||
# it. The re-arm sits before the guard (not inside a wrapping block) so the
|
||||
# guard stays the exact one-liner other tests assert; it's idempotent so
|
||||
# it. The re-arm sits before the guard (not inside a wrapping block), with
|
||||
# the updated authoritative-load check that still permits the same-session
|
||||
# guard when no different in-flight load is running; it's idempotent so
|
||||
# the real-switch path is unaffected.
|
||||
guard_ix = body.index("currentSid===sid && !forceReload && !_loadingSessionId")
|
||||
guard_ix = body.index("currentSid===sid && !forceReload && (!_loadingSessionId || _loadingSessionId===sid)")
|
||||
pre_guard = body[max(0, guard_ix - 600):guard_ix]
|
||||
assert "_rearmActiveSessionStream()" in pre_guard, (
|
||||
"a re-arm must run before the same-session no-op guard so a "
|
||||
|
||||
@@ -418,6 +418,7 @@ def test_same_session_force_reload_keeps_loaded_transcript_width_hint():
|
||||
"""Same-session force refresh must not collapse a long transcript to the tail."""
|
||||
assert "let _sameSessionForceReloadHint = null;" in SESSIONS_JS
|
||||
assert "function _captureSameSessionForceReloadHint(sid)" in SESSIONS_JS
|
||||
assert "if(!sid || _sameSessionForceReloadHint.session_id===sid) _sameSessionForceReloadHint=null;" in SESSIONS_JS
|
||||
assert "loaded_renderable_count:loadedRenderableCount" in SESSIONS_JS
|
||||
assert "message_count:knownMessageCount" in SESSIONS_JS
|
||||
assert "truncated:!!_messagesTruncated" in SESSIONS_JS
|
||||
@@ -427,7 +428,7 @@ def test_same_session_force_reload_keeps_loaded_transcript_width_hint():
|
||||
assert "return Math.max(_INITIAL_MSG_LIMIT,loadedRenderableCount,loadedMessageCount+appendedMessageCount);" in SESSIONS_JS
|
||||
assert "const reloadLimit = _messageReloadLimitForSession(sid);" in SESSIONS_JS
|
||||
assert "const reloadLimitParam = reloadLimit ? `&msg_limit=${reloadLimit}` : '';" in SESSIONS_JS
|
||||
assert "finally {\n _clearSameSessionForceReloadHint(sid);\n }" in SESSIONS_JS
|
||||
assert "if (_ownsLoad()) _clearSameSessionForceReloadHint(sid);" in SESSIONS_JS
|
||||
|
||||
load_start = SESSIONS_JS.index("async function loadSession(sid)")
|
||||
load_end = SESSIONS_JS.index("// ── Handoff hint logic", load_start)
|
||||
|
||||
Reference in new issue
Block a user