2026-07-04 22:13:30 +00:00
|
|
|
import json
|
|
|
|
|
import re
|
|
|
|
|
import subprocess
|
|
|
|
|
import textwrap
|
2026-05-13 13:21:44 +00:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8")
|
2026-05-20 14:18:56 +00:00
|
|
|
UI_JS = Path("static/ui.js").read_text(encoding="utf-8")
|
2026-06-05 07:44:03 +00:00
|
|
|
BOOT_JS = Path("static/boot.js").read_text(encoding="utf-8")
|
|
|
|
|
PANELS_JS = Path("static/panels.js").read_text(encoding="utf-8")
|
2026-05-13 13:21:44 +00:00
|
|
|
|
|
|
|
|
|
2026-07-04 22:13:30 +00:00
|
|
|
def _function_body(src: str, name: str) -> str:
|
|
|
|
|
# Extracted functions intentionally avoid braces in strings so this stays source-light.
|
|
|
|
|
m = re.search(rf"(?:async\s+)?function\s+{re.escape(name)}\b", src)
|
|
|
|
|
assert m, f"{name} function not found"
|
|
|
|
|
sig_end = src.find(")", m.end())
|
|
|
|
|
assert sig_end != -1, f"{name} function signature not terminated"
|
|
|
|
|
brace_start = src.find("{", sig_end)
|
|
|
|
|
assert brace_start != -1, f"{name} function body not found"
|
|
|
|
|
depth = 0
|
|
|
|
|
for idx in range(brace_start, len(src)):
|
|
|
|
|
ch = src[idx]
|
|
|
|
|
if ch == "{":
|
|
|
|
|
depth += 1
|
|
|
|
|
elif ch == "}":
|
|
|
|
|
depth -= 1
|
|
|
|
|
if depth == 0:
|
|
|
|
|
return src[m.start():idx + 1]
|
|
|
|
|
raise AssertionError(f"{name} function body not terminated")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_node(script: str) -> dict:
|
|
|
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
|
if result.returncode != 0:
|
|
|
|
|
raise AssertionError(f"node failed: {result.stderr}")
|
|
|
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 13:21:44 +00:00
|
|
|
def test_load_session_supports_force_reload_for_external_refresh():
|
2026-05-14 04:42:38 +00:00
|
|
|
assert "async function loadSession(sid)" in SESSIONS_JS
|
|
|
|
|
assert "const opts = arguments[1] || {};" in SESSIONS_JS
|
2026-05-13 13:21:44 +00:00
|
|
|
assert "const forceReload = !!opts.force" in SESSIONS_JS
|
|
|
|
|
assert "if(currentSid===sid && !forceReload) return;" in SESSIONS_JS
|
|
|
|
|
assert "loadSession(sid, {force:true" in SESSIONS_JS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_session_external_refresh_uses_metadata_then_force_reload():
|
|
|
|
|
assert "function ensureActiveSessionExternalRefreshPoll()" in SESSIONS_JS
|
|
|
|
|
assert "async function refreshActiveSessionIfExternallyUpdated(reason)" in SESSIONS_JS
|
|
|
|
|
assert "messages=0&resolve_model=0" in SESSIONS_JS
|
2026-06-27 18:49:08 +00:00
|
|
|
assert "if(remoteCount !== localCount)" in SESSIONS_JS
|
|
|
|
|
assert "else if(remoteLast > localLast)" in SESSIONS_JS
|
2026-05-13 13:21:44 +00:00
|
|
|
assert "if(S.busy || S.activeStreamId) return;" in SESSIONS_JS
|
|
|
|
|
assert "document.hidden" in SESSIONS_JS
|
2026-06-17 05:11:45 +00:00
|
|
|
assert "externalRefreshReason:reason||'poll'" in SESSIONS_JS
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 18:49:08 +00:00
|
|
|
def test_active_session_external_refresh_skips_destructive_reload_on_metadata_only_bump():
|
|
|
|
|
"""A timestamp-only active-session update should not blank the transcript.
|
|
|
|
|
|
|
|
|
|
Background skill/memory review can update session timestamps without adding
|
|
|
|
|
chat messages. The old `remoteCount > localCount || remoteLast > localLast`
|
|
|
|
|
condition called `loadSession(..., {force:true})` for that metadata-only
|
|
|
|
|
bump; `loadSession(force)` clears S.messages before async message fetches,
|
|
|
|
|
so the whole transcript visibly disappeared and reappeared with no new
|
|
|
|
|
content. Only a message_count CHANGE should force-reload the transcript;
|
|
|
|
|
timestamp-only bumps update local metadata and refresh the lightweight
|
|
|
|
|
sidebar list.
|
|
|
|
|
"""
|
|
|
|
|
assert "remoteCount > localCount || remoteLast > localLast" not in SESSIONS_JS
|
|
|
|
|
assert "if(remoteCount !== localCount){" in SESSIONS_JS
|
fix(webui): keep stale transcript mounted while hidden-tab return reloads new messages (#5177)
After a hidden interval that added persisted messages (post-turn bg-review
writes, sibling-tab writes, the just-finished main turn flushing), switching
the tab back caused refreshActiveSessionIfExternallyUpdated('visible'|'focus')
to hit remoteCount !== localCount and call loadSession(sid, {force:true}),
which synchronously did S.messages = [] before awaiting the metadata +
messages fetches. The visible result was the entire conversation transcript
blanking for the round-trip and reappearing — '对话突然消失,重刷才回来'.
sessions.js itself warns about this 'disappear and reappear' tradeoff at the
top of the remoteCount/remoteLast guard, but only short-circuits the
metadata-only branch (#5061). #5122's 4-probe staged reconnect covers a
different path (SSE error with ready_state=2 on a visible tab) and does not
apply when the SSE error arrives while visibility_state='hidden' — that
bottoms out through _deferStreamErrorIfPageHidden before the reconnect block
ever runs.
Fix:
- refreshActiveSessionIfExternallyUpdated maps the visibility/focus recovery
reasons ({visible, focus}) to a new keepStaleUntilLoaded option, forwarded
to loadSession. The post-stream idle reconcile and the poll/external paths
keep their existing behaviour.
- loadSession ANDs opts.keepStaleUntilLoaded with sameSessionForceReload (so
cross-session switches still clear synchronously — leaving a prior
session's transcript on screen during a navigation is the original bug
the clear was written for) and, on that path, skips the synchronous
S.messages/S.toolCalls/_messagesTruncated/_oldestIdx clear. The new
transcript is SWAPPED into S.messages by _ensureMessagesLoaded(sid,
{force:true}), so the user sees old DOM directly replaced by new DOM in a
single render frame.
- _ensureMessagesLoaded grows an opts.force escape hatch so its 'messages
already populated' early-return cannot skip the swap when stale messages
are still in place.
Verified live on a source 8701 build:
- before: minHtmlObserved 65874, minKidsObserved 11 — 372 samples at 16ms,
DOM html length NEVER dipped below the pre-call value during the reload.
- loadSession was called with force:true keepStale:true reason:'visible',
outcome 'reloaded', sMsgs 21 → 1037, after kids 11 → 134 (single-frame
swap).
Tests:
- tests/test_issue5177_hidden_tab_blank_gap.py (new, 7 cases): source-locks
the keep-stale guard shape, the recovery-reason map, the
_ensureMessagesLoaded opts.force escape hatch, and that the synchronous
clear is wrapped in if(!_keepStaleUntilLoaded).
- Updated the two existing source-lock tests whose anchor strings included
the loadSession call signature
(tests/test_webui_external_refresh_frontend.py +
tests/test_tars_scroll_reset_regressions.py) to match the new
loadSession(... keepStaleUntilLoaded:_keepStaleUntilLoaded) form. No
behavioural assertions changed.
Local: node --check static/sessions.js OK. 122 passed across
test_issue5177_hidden_tab_blank_gap.py + the full adjacent set
(external-refresh / journal-frontend / scroll-reset / reconnect-chronology /
inflight-stream-reuse / sse-error-multi-probe / issue4295 / issue4856).
2026-06-29 04:51:31 +00:00
|
|
|
assert "await loadSession(sid, {force:true, externalRefreshReason:reason||'poll', keepStaleUntilLoaded:_keepStaleUntilLoaded});" in SESSIONS_JS
|
2026-06-27 18:49:08 +00:00
|
|
|
assert "}else if(remoteLast > localLast){" in SESSIONS_JS
|
|
|
|
|
assert "S.session.last_message_at = remoteLast" in SESSIONS_JS
|
|
|
|
|
assert "if(data.session.updated_at) S.session.updated_at = data.session.updated_at;" in SESSIONS_JS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_session_external_refresh_force_reloads_on_count_decrease():
|
|
|
|
|
"""A LOWER remote message_count must still force-reload the transcript.
|
|
|
|
|
|
|
|
|
|
Another tab/client can shrink the active transcript via /api/session/truncate,
|
|
|
|
|
/retry, /undo, or regenerate — all reduce message_count while advancing
|
|
|
|
|
updated_at. A `remoteCount > localCount` gate would treat that as a
|
|
|
|
|
metadata-only bump and silently keep the stale (longer) transcript forever.
|
|
|
|
|
The condition is `remoteCount !== localCount` precisely so a decrease also
|
|
|
|
|
re-syncs.
|
|
|
|
|
"""
|
|
|
|
|
# The force-reload branch must trigger on ANY count change, not just growth.
|
|
|
|
|
assert "if(remoteCount !== localCount){" in SESSIONS_JS
|
|
|
|
|
assert "if(remoteCount > localCount){" not in SESSIONS_JS
|
|
|
|
|
# The metadata-only branch must be gated on an unchanged count (the else of
|
|
|
|
|
# the count-change check), never reachable when the count differs.
|
|
|
|
|
assert "}else if(remoteLast > localLast){" in SESSIONS_JS
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 05:11:45 +00:00
|
|
|
def test_webui_source_never_counts_as_external_session():
|
|
|
|
|
assert "function _isWebUiSourceSession(session)" in SESSIONS_JS
|
|
|
|
|
assert "if (!session || _isWebUiSourceSession(session)) return false;" in SESSIONS_JS
|
|
|
|
|
external_start = SESSIONS_JS.index("function _isExternalSession(session)")
|
|
|
|
|
external_body = SESSIONS_JS[external_start : external_start + 300]
|
|
|
|
|
assert "_isWebUiSourceSession(session)" in external_body
|
|
|
|
|
assert "session.is_cli_session || _isMessagingSession(session)" in external_body
|
2026-05-13 13:21:44 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_session_external_refresh_has_focus_and_visibility_hooks():
|
|
|
|
|
assert "visibilitychange" in SESSIONS_JS
|
|
|
|
|
assert "window.addEventListener('focus'" in SESSIONS_JS
|
|
|
|
|
assert "ensureActiveSessionExternalRefreshPoll();" in SESSIONS_JS
|
2026-05-13 20:27:43 +00:00
|
|
|
|
|
|
|
|
|
2026-07-03 18:13:24 +00:00
|
|
|
def test_session_time_refresh_has_own_visibility_hook():
|
|
|
|
|
"""Skipped hidden-tab timestamp ticks must refresh immediately on return."""
|
|
|
|
|
start = SESSIONS_JS.find("function ensureSessionTimeRefreshPoll()")
|
|
|
|
|
assert start != -1
|
|
|
|
|
block = SESSIONS_JS[start:start + 900]
|
|
|
|
|
assert "_sessionTimeRefreshVisibilityHandler" in SESSIONS_JS
|
|
|
|
|
assert "document.addEventListener('visibilitychange', _sessionTimeRefreshVisibilityHandler);" in block
|
|
|
|
|
assert "if(!document.hidden) renderSessionListFromCache();" in block
|
|
|
|
|
assert "startStreamingPoll re-renders the list" not in block
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 14:18:56 +00:00
|
|
|
def test_session_list_external_refresh_uses_sse_invalidation_not_polling():
|
|
|
|
|
"""New sessions should refresh the sidebar from server invalidation events."""
|
|
|
|
|
assert "async function refreshSessionList(reason='manual', opts={})" in SESSIONS_JS
|
2026-07-04 22:13:30 +00:00
|
|
|
assert "let _sessionListRefreshPendingRequest = null;" in SESSIONS_JS
|
|
|
|
|
assert "function _mergeSessionListRefreshOptions(prev, next)" in SESSIONS_JS
|
|
|
|
|
assert "function _refreshSessionListAfterSidebarResume(reason)" in SESSIONS_JS
|
|
|
|
|
assert "_sessionEventsNeedsRefreshOnOpen = false" in SESSIONS_JS
|
|
|
|
|
assert "void refreshSessionList(reason, {force:true})" in SESSIONS_JS
|
2026-05-20 14:18:56 +00:00
|
|
|
assert "function ensureSessionEventsSSE()" in SESSIONS_JS
|
|
|
|
|
assert "new EventSource('api/sessions/events')" in SESSIONS_JS
|
|
|
|
|
assert "addEventListener('sessions_changed'" in SESSIONS_JS
|
2026-07-04 22:13:30 +00:00
|
|
|
assert "function _scheduleSessionEventsRefresh(reason, opts={})" in SESSIONS_JS
|
|
|
|
|
assert "let _sessionEventsRefreshPendingRequest = null;" in SESSIONS_JS
|
2026-05-20 14:18:56 +00:00
|
|
|
assert "_sessionEventsNeedsRefreshOnOpen = true" in SESSIONS_JS
|
2026-07-04 22:13:30 +00:00
|
|
|
assert "void _refreshSessionListAfterSidebarResume('focus')" in SESSIONS_JS
|
|
|
|
|
assert "void _refreshSessionListAfterSidebarResume('visible')" in SESSIONS_JS
|
|
|
|
|
assert "void _refreshSessionListAfterSidebarResume('reconnect')" in SESSIONS_JS
|
2026-05-20 14:18:56 +00:00
|
|
|
assert "renderSessionList({deferWhileInteracting:!force})" in SESSIONS_JS
|
2026-05-20 20:40:13 +00:00
|
|
|
assert "const refreshActive = !!(opts && opts.refreshActive)" in SESSIONS_JS
|
|
|
|
|
assert "if(refreshActive) await refreshActiveSessionIfExternallyUpdated(reason||'session-list')" in SESSIONS_JS
|
2026-07-04 22:13:30 +00:00
|
|
|
assert "_sessionListRefreshPendingRequest = {" in SESSIONS_JS
|
|
|
|
|
assert "_scheduleSessionEventsRefresh(pendingRequest.reason, pendingRequest.opts)" in SESSIONS_JS
|
2026-05-20 14:18:56 +00:00
|
|
|
assert "ensureSessionEventsSSE();" in SESSIONS_JS
|
|
|
|
|
assert "document._hermesSessionEventsVisibilityHook" in SESSIONS_JS
|
2026-05-20 20:33:36 +00:00
|
|
|
ensure_fn = SESSIONS_JS[SESSIONS_JS.find("function ensureSessionEventsSSE()") :]
|
2026-06-14 00:46:33 +00:00
|
|
|
# The visibility hook must be installed before the open-guard early-return.
|
|
|
|
|
# #4151 replaced the `document.hidden) return` open guard with the focus-aware
|
|
|
|
|
# `_sidebarSseBackgrounded()) return` predicate (which also covers PWA blur).
|
|
|
|
|
assert ensure_fn.find("document._hermesSessionEventsVisibilityHook") < ensure_fn.find("_sidebarSseBackgrounded()) return")
|
2026-05-20 14:18:56 +00:00
|
|
|
assert "_sessionListExternalRefreshMs" not in SESSIONS_JS
|
2026-06-05 07:44:03 +00:00
|
|
|
assert "addEventListener('sessions_changed', (ev) => {" in ensure_fn
|
|
|
|
|
assert "const activeProfile = S.activeProfile || 'default';" in ensure_fn
|
|
|
|
|
assert "const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data) : {};" in ensure_fn
|
|
|
|
|
assert "const eventProfile = payload && typeof payload.profile === 'string' ? payload.profile : '';" in ensure_fn
|
|
|
|
|
assert "if (!_sessionEventProfilesMatch(eventProfile, activeProfile)) {" in ensure_fn
|
2026-06-15 07:10:01 +00:00
|
|
|
assert "function _sessionEventTargetsActiveSession(payload)" in SESSIONS_JS
|
|
|
|
|
assert "typeof payload.session_id === 'string'" in SESSIONS_JS
|
|
|
|
|
assert "eventTargetsActiveSession?'event-active-session':'event'" in ensure_fn
|
2026-07-04 22:13:30 +00:00
|
|
|
assert "_scheduleSessionEventsRefresh(eventTargetsActiveSession?'event-active-session':'event', {force:true, refreshActive:true})" in ensure_fn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_events_refresh_forces_hidden_sidebar_render_from_event_path():
|
|
|
|
|
"""The production sessions_changed path must force the hidden sidebar list render."""
|
|
|
|
|
merge_body = (
|
|
|
|
|
_function_body(SESSIONS_JS, "_mergeSessionListRefreshOptions")
|
|
|
|
|
if "function _mergeSessionListRefreshOptions" in SESSIONS_JS
|
|
|
|
|
else "function _mergeSessionListRefreshOptions(prev, next){ return {...(prev||{}), ...(next||{})}; }"
|
|
|
|
|
)
|
|
|
|
|
functions = "\n\n".join(
|
|
|
|
|
[
|
|
|
|
|
merge_body,
|
|
|
|
|
_function_body(SESSIONS_JS, "refreshSessionList"),
|
|
|
|
|
_function_body(SESSIONS_JS, "_scheduleSessionEventsRefresh"),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
script = textwrap.dedent(
|
|
|
|
|
f"""
|
|
|
|
|
const record = [];
|
|
|
|
|
global.document = {{hidden: true}};
|
|
|
|
|
global.renderSessionList = async (opts) => record.push({{kind: 'render', opts}});
|
|
|
|
|
global.refreshActiveSessionIfExternallyUpdated = async (reason) => record.push({{kind: 'active', reason}});
|
|
|
|
|
global.setTimeout = (cb) => {{
|
|
|
|
|
cb();
|
|
|
|
|
return 1;
|
|
|
|
|
}};
|
|
|
|
|
let _sessionListRefreshInFlight = false;
|
|
|
|
|
let _sessionListRefreshPendingRequest = null;
|
|
|
|
|
let _sessionEventsRefreshTimer = 0;
|
|
|
|
|
let _sessionEventsRefreshPendingRequest = null;
|
|
|
|
|
{functions}
|
|
|
|
|
(async() => {{
|
|
|
|
|
_scheduleSessionEventsRefresh('event-active-session', {{force:true, refreshActive:true}});
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
process.stdout.write(JSON.stringify(record));
|
|
|
|
|
}})().catch((err) => {{
|
|
|
|
|
process.stderr.write(String(err.stack || err) + '\\n');
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}});
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
out = _run_node(script)
|
|
|
|
|
assert out == [
|
|
|
|
|
{"kind": "render", "opts": {"deferWhileInteracting": False}},
|
|
|
|
|
{"kind": "active", "reason": "event-active-session"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_list_external_refresh_forced_resume_survives_hidden_inflight_refresh():
|
|
|
|
|
"""Queued resume refreshes must keep `force:true` even after a hidden invalidation."""
|
|
|
|
|
functions = "\n\n".join(
|
|
|
|
|
_function_body(SESSIONS_JS, name)
|
|
|
|
|
for name in (
|
|
|
|
|
"_mergeSessionListRefreshOptions",
|
|
|
|
|
"refreshSessionList",
|
|
|
|
|
"_scheduleSessionEventsRefresh",
|
|
|
|
|
"_refreshSessionListAfterSidebarResume",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
script = textwrap.dedent(
|
|
|
|
|
f"""
|
|
|
|
|
const record = [];
|
|
|
|
|
const state = {{hidden: false}};
|
|
|
|
|
global.document = state;
|
|
|
|
|
let renderCount = 0;
|
|
|
|
|
global.renderSessionList = async (opts) => {{
|
|
|
|
|
record.push({{kind: 'render', opts}});
|
|
|
|
|
renderCount += 1;
|
|
|
|
|
if (renderCount === 1) {{
|
|
|
|
|
state.hidden = true;
|
|
|
|
|
await refreshSessionList('focus', {{force:true}});
|
|
|
|
|
}}
|
|
|
|
|
}};
|
|
|
|
|
global.refreshActiveSessionIfExternallyUpdated = async (reason) => {{
|
|
|
|
|
record.push({{kind: 'active', reason}});
|
|
|
|
|
}};
|
|
|
|
|
global.setTimeout = (cb) => {{
|
|
|
|
|
cb();
|
|
|
|
|
return 1;
|
|
|
|
|
}};
|
|
|
|
|
global.clearTimeout = () => {{}};
|
|
|
|
|
let _sessionListRefreshInFlight = false;
|
|
|
|
|
let _sessionListRefreshPendingRequest = null;
|
|
|
|
|
let _sessionEventsRefreshTimer = 0;
|
|
|
|
|
let _sessionEventsRefreshPendingRequest = null;
|
|
|
|
|
{functions}
|
|
|
|
|
(async() => {{
|
|
|
|
|
await refreshSessionList('event', {{refreshActive:true}});
|
|
|
|
|
await refreshSessionList('manual');
|
|
|
|
|
process.stdout.write(JSON.stringify({{
|
|
|
|
|
record,
|
|
|
|
|
resumeSrc: _refreshSessionListAfterSidebarResume.toString(),
|
|
|
|
|
}}));
|
|
|
|
|
}})().catch((err) => {{
|
|
|
|
|
process.stderr.write(String(err.stack || err) + '\\n');
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}});
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
out = _run_node(script)
|
|
|
|
|
assert out["record"] == [
|
|
|
|
|
{"kind": "render", "opts": {"deferWhileInteracting": True}},
|
|
|
|
|
{"kind": "active", "reason": "event"},
|
|
|
|
|
{"kind": "render", "opts": {"deferWhileInteracting": False}},
|
|
|
|
|
]
|
|
|
|
|
assert "refreshSessionList(reason, {force:true})" in out["resumeSrc"]
|
|
|
|
|
assert "refreshActive:true" not in out["resumeSrc"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_events_refresh_timer_merges_pending_force_and_active_options():
|
|
|
|
|
"""The debounce window must not drop force or active-refresh intent."""
|
|
|
|
|
functions = "\n\n".join(
|
|
|
|
|
_function_body(SESSIONS_JS, name)
|
|
|
|
|
for name in (
|
|
|
|
|
"_mergeSessionListRefreshOptions",
|
|
|
|
|
"refreshSessionList",
|
|
|
|
|
"_scheduleSessionEventsRefresh",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
script = textwrap.dedent(
|
|
|
|
|
f"""
|
|
|
|
|
const record = [];
|
|
|
|
|
global.document = {{hidden: true}};
|
|
|
|
|
global.renderSessionList = async (opts) => record.push({{kind: 'render', opts}});
|
|
|
|
|
global.refreshActiveSessionIfExternallyUpdated = async (reason) => record.push({{kind: 'active', reason}});
|
|
|
|
|
let timerCb = null;
|
|
|
|
|
global.setTimeout = (cb) => {{ timerCb = cb; return 1; }};
|
|
|
|
|
global.clearTimeout = () => {{}};
|
|
|
|
|
let _sessionListRefreshInFlight = false;
|
|
|
|
|
let _sessionListRefreshPendingRequest = null;
|
|
|
|
|
let _sessionEventsRefreshTimer = 0;
|
|
|
|
|
let _sessionEventsRefreshPendingRequest = null;
|
|
|
|
|
{functions}
|
|
|
|
|
(async() => {{
|
|
|
|
|
_scheduleSessionEventsRefresh('focus', {{force:true}});
|
|
|
|
|
_scheduleSessionEventsRefresh('event-active-session', {{refreshActive:true}});
|
|
|
|
|
timerCb();
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
process.stdout.write(JSON.stringify(record));
|
|
|
|
|
}})().catch((err) => {{
|
|
|
|
|
process.stderr.write(String(err.stack || err) + '\\n');
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}});
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
assert _run_node(script) == [
|
|
|
|
|
{"kind": "render", "opts": {"deferWhileInteracting": False}},
|
|
|
|
|
{"kind": "active", "reason": "event-active-session"},
|
|
|
|
|
]
|
2026-06-05 07:44:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_event_profile_filter_tolerates_default_root_aliases():
|
|
|
|
|
assert "function _profileMatchesActiveProfile(profile, activeProfile)" in SESSIONS_JS
|
|
|
|
|
assert "return eventName === 'default' && !!S.activeProfileIsDefault;" in SESSIONS_JS
|
|
|
|
|
assert "function _sessionEventProfilesMatch(eventProfile, activeProfile)" in SESSIONS_JS
|
|
|
|
|
assert "if (!_profileMatchesActiveProfile(sessionProfile, activeProfile)) return false;" in SESSIONS_JS
|
|
|
|
|
assert "activeProfileIsDefault:true" in UI_JS
|
2026-06-26 20:45:38 +00:00
|
|
|
assert "const activeProfileState = await _resolveActiveProfileBootstrapState();" in BOOT_JS
|
|
|
|
|
assert "S.activeProfileIsDefault = activeProfileState.isDefault;" in BOOT_JS
|
2026-06-05 07:44:03 +00:00
|
|
|
assert "S.activeProfileIsDefault = !!data.is_default;" in PANELS_JS
|
2026-05-20 14:18:56 +00:00
|
|
|
|
|
|
|
|
|
2026-07-03 22:58:21 +00:00
|
|
|
def test_session_list_render_signature_serializes_full_rows_not_a_narrow_allowlist():
|
|
|
|
|
"""The render-skip signature must serialize the FULL applied rows (+ reference
|
|
|
|
|
rows), not a curated field subset — a narrow allowlist silently false-skips
|
|
|
|
|
when a rendered field it omits changes (Codex #5467: it dropped pending/running
|
|
|
|
|
streaming state, attention dots, and the source/lineage cluster, so an
|
|
|
|
|
approval/clarify transition or a row starting to stream could keep a stale
|
|
|
|
|
sidebar). Serializing the whole row objects covers every field the render
|
|
|
|
|
helpers read, present and future.
|
|
|
|
|
"""
|
2026-07-03 18:10:44 +00:00
|
|
|
start = SESSIONS_JS.find("function _sessionListRenderSignature()")
|
|
|
|
|
assert start != -1
|
2026-07-03 22:58:21 +00:00
|
|
|
block = SESSIONS_JS[start:start + 900]
|
|
|
|
|
# Full-object serialization, not a hand-picked allowlist.
|
|
|
|
|
assert "const sessionKeys = [" not in block, \
|
|
|
|
|
"signature must not use a narrow field allowlist (it false-skips on omitted fields)"
|
|
|
|
|
assert "sessionKeys.forEach" not in block
|
|
|
|
|
assert "_allSessions," in block, "signature must serialize the full applied rows"
|
|
|
|
|
assert "_sidebarReferenceSessions," in block, \
|
|
|
|
|
"signature must include the hidden reference/nesting rows the sidebar renders"
|
|
|
|
|
# Fail-open on serialization failure (never skip on null).
|
|
|
|
|
assert "catch(_){ return null; }" in block
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_list_render_signature_changes_on_pending_running_and_attention():
|
|
|
|
|
"""Regression for the Codex #5467 CORE/SILENT false-skips: because the
|
|
|
|
|
signature serializes whole rows, a change to pending/running state or the
|
|
|
|
|
attention dot changes the signature (so the sidebar repaints, not skips).
|
|
|
|
|
This is a structural guarantee of full-row serialization — assert the
|
|
|
|
|
signature does not strip those fields back out.
|
|
|
|
|
"""
|
|
|
|
|
start = SESSIONS_JS.find("function _sessionListRenderSignature()")
|
|
|
|
|
block = SESSIONS_JS[start:start + 900]
|
|
|
|
|
# None of the previously-omitted, render-consumed fields may be filtered out.
|
|
|
|
|
for stripped in (
|
|
|
|
|
"delete out.active_stream_id",
|
|
|
|
|
"delete out.attention",
|
|
|
|
|
"delete out.has_pending_user_message",
|
|
|
|
|
):
|
|
|
|
|
assert stripped not in block, f"signature must not strip {stripped!r}"
|
|
|
|
|
# The whole-object arrays are passed straight to JSON.stringify.
|
|
|
|
|
assert "JSON.stringify([" in block
|
2026-07-03 18:10:44 +00:00
|
|
|
|
|
|
|
|
|
2026-07-03 23:04:41 +00:00
|
|
|
def test_session_list_render_signature_does_not_skip_recovering_from_skeleton_or_error():
|
|
|
|
|
"""The render-skip must never fire when recovering from a skeleton or the
|
|
|
|
|
'Could not load conversations' banner — both are rendered OUTSIDE the
|
|
|
|
|
signature path, so an identical-signature match (empty/same-shaped profile,
|
|
|
|
|
or a transient fetch failure healing with identical rows) would leave the
|
|
|
|
|
skeleton/error DOM on screen instead of the real list. (Codex #5467 re-gate)
|
|
|
|
|
"""
|
|
|
|
|
start = SESSIONS_JS.find("function _applySessionListPayload(")
|
|
|
|
|
assert start != -1
|
2026-07-03 23:05:03 +00:00
|
|
|
body = SESSIONS_JS[start:start + 12000]
|
2026-07-03 23:04:41 +00:00
|
|
|
assert "const _hadSessionListSkeleton = _sessionListSkeletonActive;" in body
|
|
|
|
|
assert "const _hadSessionListLoadError = !!_sessionListLoadError;" in body
|
|
|
|
|
assert "const _mustForceRender = _hadSessionListSkeleton || _hadSessionListLoadError;" in body
|
|
|
|
|
assert "!_mustForceRender" in body, "the force flag must gate the identical-signature skip"
|
|
|
|
|
# captured BEFORE the respective clears
|
|
|
|
|
assert body.index("const _hadSessionListSkeleton") < body.index("_sessionListSkeletonActive = false;")
|
|
|
|
|
assert body.index("const _hadSessionListLoadError") < body.index("_sessionListLoadError = null;")
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 14:18:56 +00:00
|
|
|
def test_pwa_pull_to_refresh_refreshes_session_list_not_page_when_available():
|
2026-05-20 20:40:13 +00:00
|
|
|
assert "window.refreshSessionList('pull', {force:true, refreshActive:true})" in UI_JS
|
|
|
|
|
assert "Promise.resolve(window.refreshSessionList('pull', {force:true, refreshActive:true})).catch(()=>{}).finally(_ptrReset)" in UI_JS
|
2026-05-20 14:18:56 +00:00
|
|
|
|
|
|
|
|
|
2026-05-13 20:27:43 +00:00
|
|
|
def test_force_reload_clears_stale_blocking_prompts_immediately():
|
|
|
|
|
"""External refresh should not leave old approval/clarify modals blocking the composer.
|
|
|
|
|
|
|
|
|
|
hideApprovalCard() and hideClarifyCard() defer hiding for their minimum-visible
|
|
|
|
|
timers unless force=true. That is correct for active streams, but when a
|
|
|
|
|
same-session external state.db update triggers loadSession(..., {force:true}),
|
|
|
|
|
the session has completed elsewhere and stale prompts should be removed now.
|
|
|
|
|
"""
|
|
|
|
|
assert "hideApprovalCard(forceReload)" in SESSIONS_JS
|
|
|
|
|
assert "hideClarifyCard(forceReload, forceReload?'external-refresh':'dismissed')" in SESSIONS_JS
|
2026-05-19 19:25:16 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_same_session_force_reload_preserves_non_empty_composer_input():
|
|
|
|
|
"""A slow same-session refresh must not roll back text typed meanwhile.
|
|
|
|
|
|
|
|
|
|
The active-session refresh path can finish seconds after it started. If the
|
|
|
|
|
user kept typing, restoring the server draft at the end of that load would
|
|
|
|
|
replace newer local input with an older debounced draft.
|
|
|
|
|
"""
|
|
|
|
|
assert "function _restoreComposerDraft(draft, targetSid, opts={})" in SESSIONS_JS
|
|
|
|
|
assert "const preserveActiveInput = !!(opts && opts.preserveActiveInput);" in SESSIONS_JS
|
|
|
|
|
assert "if (preserveActiveInput && current && current !== text) return;" in SESSIONS_JS
|
2026-06-27 20:51:46 +00:00
|
|
|
assert "_restoreComposerDraft(_draft, sid, {preserveActiveInput:!!opts.preserveActiveInput || (currentSid===sid&&forceReload)});" in SESSIONS_JS
|
2026-06-02 18:53:12 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-10 17:00:49 +00:00
|
|
|
assert "if(!sid || _sameSessionForceReloadHint.session_id===sid) _sameSessionForceReloadHint=null;" in SESSIONS_JS
|
2026-06-02 18:53:12 +00:00
|
|
|
assert "loaded_renderable_count:loadedRenderableCount" in SESSIONS_JS
|
|
|
|
|
assert "message_count:knownMessageCount" in SESSIONS_JS
|
|
|
|
|
assert "truncated:!!_messagesTruncated" in SESSIONS_JS
|
|
|
|
|
assert "function _messageReloadLimitForSession(sid)" in SESSIONS_JS
|
|
|
|
|
assert "if(!hint.truncated) return null;" in SESSIONS_JS
|
|
|
|
|
assert "const appendedMessageCount=Math.max(0,currentMessageCount-previousMessageCount);" in SESSIONS_JS
|
|
|
|
|
assert "return Math.max(_INITIAL_MSG_LIMIT,loadedRenderableCount,loadedMessageCount+appendedMessageCount);" in SESSIONS_JS
|
|
|
|
|
assert "const reloadLimit = _messageReloadLimitForSession(sid);" in SESSIONS_JS
|
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
|
|
|
# The width hint is applied only when it stays within the server msg_limit
|
|
|
|
|
# ceiling; an over-ceiling hint would be clamped by the backend and could
|
|
|
|
|
# silently shrink an already-loaded transcript, so it falls back to the bare
|
|
|
|
|
# full-transcript path (#6152/#6154 ceiling; Codex gate silent row-loss fix).
|
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: the ceiling is now read from /api/session metadata into _msgLimitMax
|
|
|
|
|
# (module-scope let, default _MSG_LIMIT_MAX) instead of the mirrored const.
|
|
|
|
|
assert "const boundedReloadLimit = (reloadLimit && reloadLimit <= _msgLimitMax) ? reloadLimit : null;" in SESSIONS_JS
|
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
|
|
|
assert "const reloadLimitParam = boundedReloadLimit ? `&msg_limit=${boundedReloadLimit}` : '';" in SESSIONS_JS
|
2026-07-10 17:00:49 +00:00
|
|
|
assert "if (_ownsLoad()) _clearSameSessionForceReloadHint(sid);" in SESSIONS_JS
|
2026-06-02 18:53:12 +00:00
|
|
|
|
|
|
|
|
load_start = SESSIONS_JS.index("async function loadSession(sid)")
|
|
|
|
|
load_end = SESSIONS_JS.index("// ── Handoff hint logic", load_start)
|
|
|
|
|
load_body = SESSIONS_JS[load_start:load_end]
|
|
|
|
|
capture_pos = load_body.index("if (sameSessionForceReload) _captureSameSessionForceReloadHint(sid);")
|
|
|
|
|
clear_pos = load_body.index("else _clearSameSessionForceReloadHint();", capture_pos)
|
|
|
|
|
reset_pos = load_body.index("S.messages = [];", clear_pos)
|
|
|
|
|
assert capture_pos < clear_pos < reset_pos
|
|
|
|
|
assert "const sameSessionForceReload = forceReload && currentSid===sid;" in load_body
|
|
|
|
|
assert "renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined)" in load_body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_same_width_force_reload_invalidates_visible_message_cache():
|
|
|
|
|
"""Replacing a transcript with the same length must still refresh cached rows."""
|
|
|
|
|
clear_start = UI_JS.index("function clearVisibleMessageRowCache()")
|
|
|
|
|
clear_end = UI_JS.index("function _resetMessageRenderWindow", clear_start)
|
|
|
|
|
clear_body = UI_JS[clear_start:clear_end]
|
|
|
|
|
assert "_visWithIdxCache=null;" in clear_body
|
|
|
|
|
assert "_visWithIdxCacheLen=0;" in clear_body
|
|
|
|
|
assert "clearVisibleMessageRowCache();" in UI_JS[UI_JS.index("function clearMessageRenderCache()") :]
|
|
|
|
|
|
fix(webui): keep stale transcript mounted while hidden-tab return reloads new messages (#5177)
After a hidden interval that added persisted messages (post-turn bg-review
writes, sibling-tab writes, the just-finished main turn flushing), switching
the tab back caused refreshActiveSessionIfExternallyUpdated('visible'|'focus')
to hit remoteCount !== localCount and call loadSession(sid, {force:true}),
which synchronously did S.messages = [] before awaiting the metadata +
messages fetches. The visible result was the entire conversation transcript
blanking for the round-trip and reappearing — '对话突然消失,重刷才回来'.
sessions.js itself warns about this 'disappear and reappear' tradeoff at the
top of the remoteCount/remoteLast guard, but only short-circuits the
metadata-only branch (#5061). #5122's 4-probe staged reconnect covers a
different path (SSE error with ready_state=2 on a visible tab) and does not
apply when the SSE error arrives while visibility_state='hidden' — that
bottoms out through _deferStreamErrorIfPageHidden before the reconnect block
ever runs.
Fix:
- refreshActiveSessionIfExternallyUpdated maps the visibility/focus recovery
reasons ({visible, focus}) to a new keepStaleUntilLoaded option, forwarded
to loadSession. The post-stream idle reconcile and the poll/external paths
keep their existing behaviour.
- loadSession ANDs opts.keepStaleUntilLoaded with sameSessionForceReload (so
cross-session switches still clear synchronously — leaving a prior
session's transcript on screen during a navigation is the original bug
the clear was written for) and, on that path, skips the synchronous
S.messages/S.toolCalls/_messagesTruncated/_oldestIdx clear. The new
transcript is SWAPPED into S.messages by _ensureMessagesLoaded(sid,
{force:true}), so the user sees old DOM directly replaced by new DOM in a
single render frame.
- _ensureMessagesLoaded grows an opts.force escape hatch so its 'messages
already populated' early-return cannot skip the swap when stale messages
are still in place.
Verified live on a source 8701 build:
- before: minHtmlObserved 65874, minKidsObserved 11 — 372 samples at 16ms,
DOM html length NEVER dipped below the pre-call value during the reload.
- loadSession was called with force:true keepStale:true reason:'visible',
outcome 'reloaded', sMsgs 21 → 1037, after kids 11 → 134 (single-frame
swap).
Tests:
- tests/test_issue5177_hidden_tab_blank_gap.py (new, 7 cases): source-locks
the keep-stale guard shape, the recovery-reason map, the
_ensureMessagesLoaded opts.force escape hatch, and that the synchronous
clear is wrapped in if(!_keepStaleUntilLoaded).
- Updated the two existing source-lock tests whose anchor strings included
the loadSession call signature
(tests/test_webui_external_refresh_frontend.py +
tests/test_tars_scroll_reset_regressions.py) to match the new
loadSession(... keepStaleUntilLoaded:_keepStaleUntilLoaded) form. No
behavioural assertions changed.
Local: node --check static/sessions.js OK. 122 passed across
test_issue5177_hidden_tab_blank_gap.py + the full adjacent set
(external-refresh / journal-frontend / scroll-reset / reconnect-chronology /
inflight-stream-reuse / sse-error-multi-probe / issue4295 / issue4856).
2026-06-29 04:51:31 +00:00
|
|
|
ensure_start = SESSIONS_JS.index("async function _ensureMessagesLoaded(sid")
|
2026-06-02 18:53:12 +00:00
|
|
|
ensure_end = SESSIONS_JS.index("function _messageComparableText", ensure_start)
|
|
|
|
|
ensure_body = SESSIONS_JS[ensure_start:ensure_end]
|
|
|
|
|
invalidate_pos = ensure_body.index("if(typeof clearVisibleMessageRowCache==='function') clearVisibleMessageRowCache();")
|
|
|
|
|
replace_pos = ensure_body.index("S.messages = msgs;")
|
|
|
|
|
assert invalidate_pos < replace_pos
|