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).
158 lines
6.7 KiB
Python
158 lines
6.7 KiB
Python
"""Regression test pinning the #3306 fix.
|
|
|
|
#3306: `loadSession()` in static/sessions.js cleared `S.messages = []` BEFORE
|
|
issuing the API fetch and BEFORE `_ensureMessagesLoaded()` invoked the #3018
|
|
ephemeral-field carry-forward (`_carryForwardEphemeralTurnFields(S.messages||[],
|
|
msgs)`). Because the clear happened first, the carry-forward saw an empty
|
|
array and ephemeral turn fields (_turnUsage, _turnDuration, _turnTps,
|
|
_gatewayRouting, _statusCard) were dropped on every force-reload. The visible
|
|
symptom: the token-usage badge vanished ~10s after each assistant turn finished
|
|
when an external poll triggered loadSession(..., forceReload).
|
|
|
|
Fix: snapshot S.messages BEFORE the clear (only when force-reloading the
|
|
currently-active session) into a module-level `_pendingCarryForwardSnapshot`,
|
|
then consume it in `_ensureMessagesLoaded()` ahead of the live S.messages
|
|
(which is now []).
|
|
|
|
This file is the targeted source-text pin in the same style as
|
|
tests/test_issue3162_ensure_messages_loaded.py.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _function_body(start_marker: str, end_marker: str) -> str:
|
|
start = SESSIONS_JS.index(start_marker)
|
|
end = SESSIONS_JS.index(end_marker, start)
|
|
return SESSIONS_JS[start:end]
|
|
|
|
|
|
def _load_session_clear_block() -> str:
|
|
"""The if(currentSid!==sid||forceReload){...} block in loadSession()."""
|
|
start = SESSIONS_JS.index("async function loadSession(sid)")
|
|
clear_start = SESSIONS_JS.index("if (currentSid !== sid || forceReload) {", start)
|
|
clear_end = SESSIONS_JS.index("// Phase 1: Load metadata only", clear_start)
|
|
return SESSIONS_JS[clear_start:clear_end]
|
|
|
|
|
|
def _ensure_messages_loaded_body() -> str:
|
|
return _function_body(
|
|
"async function _ensureMessagesLoaded(sid",
|
|
"function _messageComparableText",
|
|
)
|
|
|
|
|
|
def test_pending_carry_forward_snapshot_declared_at_module_scope():
|
|
assert "let _pendingCarryForwardSnapshot" in SESSIONS_JS, (
|
|
"module-level _pendingCarryForwardSnapshot is the bridge between "
|
|
"loadSession()'s pre-clear snapshot and _ensureMessagesLoaded()'s "
|
|
"carry-forward call — required by #3306 fix"
|
|
)
|
|
|
|
|
|
def test_loadsession_snapshots_messages_before_clearing():
|
|
body = _load_session_clear_block()
|
|
# The assignment to _pendingCarryForwardSnapshot must appear before the
|
|
# `S.messages = [];` clear inside the if-block.
|
|
assign_idx = body.find("_pendingCarryForwardSnapshot =")
|
|
clear_idx = body.find("S.messages = [];")
|
|
assert assign_idx != -1, (
|
|
"#3306: loadSession() must snapshot S.messages into "
|
|
"_pendingCarryForwardSnapshot before clearing — snapshot assignment missing"
|
|
)
|
|
assert clear_idx != -1, "S.messages = [] clear not found in loadSession()"
|
|
assert assign_idx < clear_idx, (
|
|
"#3306: _pendingCarryForwardSnapshot must be assigned BEFORE "
|
|
"`S.messages = []`, otherwise the snapshot captures an already-empty array"
|
|
)
|
|
|
|
|
|
def test_loadsession_snapshot_gated_on_force_reload_of_active_session():
|
|
body = _load_session_clear_block()
|
|
# The snapshot should only happen when reloading the currently-active session.
|
|
assert "currentSid === sid && forceReload" in body, (
|
|
"#3306: snapshot must be gated on `currentSid === sid && forceReload` "
|
|
"so switching to a different session still gets a clean carry-forward "
|
|
"(prior messages would belong to a different conversation)"
|
|
)
|
|
|
|
|
|
def test_ensure_messages_loaded_consumes_snapshot_then_clears_it():
|
|
body = _ensure_messages_loaded_body()
|
|
assert "_pendingCarryForwardSnapshot" in body, (
|
|
"#3306: _ensureMessagesLoaded() must consult _pendingCarryForwardSnapshot "
|
|
"when calling _carryForwardEphemeralTurnFields"
|
|
)
|
|
# And must reset it afterwards so subsequent non-force loads don't reuse
|
|
# a stale snapshot.
|
|
assert "_pendingCarryForwardSnapshot = null" in body, (
|
|
"#3306: _ensureMessagesLoaded() must reset _pendingCarryForwardSnapshot "
|
|
"to null after consuming it, to avoid leaking stale ephemeral fields "
|
|
"into a later unrelated load"
|
|
)
|
|
|
|
|
|
def _load_older_messages_body() -> str:
|
|
return _function_body(
|
|
"async function _loadOlderMessages() {",
|
|
"async function _ensureAllMessagesLoaded",
|
|
)
|
|
|
|
|
|
def _start_gateway_sse_body() -> str:
|
|
return _function_body(
|
|
"function startGatewaySSE(){",
|
|
"function stopGatewaySSE",
|
|
)
|
|
|
|
|
|
def test_load_older_messages_tail_match_carries_forward():
|
|
"""#3306 follow-up: _loadOlderMessages does a wholesale `S.messages = nextMessages`
|
|
on the tail-match path. Without carry-forward this drops ephemeral turn fields
|
|
(the intermittent "badge appears then disappears" symptom flagged in review)."""
|
|
body = _load_older_messages_body()
|
|
cf_idx = body.find("_carryForwardEphemeralTurnFields(S.messages || [], nextMessages)")
|
|
assign_idx = body.find("S.messages = nextMessages;")
|
|
assert cf_idx != -1, (
|
|
"#3306: _loadOlderMessages must carry forward ephemeral turn fields "
|
|
"into nextMessages before the wholesale S.messages assignment"
|
|
)
|
|
assert assign_idx != -1, "S.messages = nextMessages assignment not found"
|
|
assert cf_idx < assign_idx, (
|
|
"#3306: carry-forward call must precede `S.messages = nextMessages` "
|
|
"in _loadOlderMessages, otherwise the fields are already gone"
|
|
)
|
|
|
|
|
|
def test_start_gateway_sse_import_cli_carries_forward():
|
|
"""#3306 follow-up: the gateway SSE → import_cli refresh path also does a
|
|
wholesale `S.messages = next` for CLI sessions; it must carry forward
|
|
ephemeral turn fields too."""
|
|
body = _start_gateway_sse_body()
|
|
cf_idx = body.find("_carryForwardEphemeralTurnFields(S.messages || [], next)")
|
|
assign_idx = body.find("S.messages = _nextToAssign;")
|
|
assert cf_idx != -1, (
|
|
"#3306: startGatewaySSE import_cli branch must carry forward "
|
|
"ephemeral turn fields before assigning S.messages"
|
|
)
|
|
assert assign_idx != -1, (
|
|
"expected `S.messages = _nextToAssign;` after carry-forward in "
|
|
"startGatewaySSE import_cli branch"
|
|
)
|
|
assert cf_idx < assign_idx, (
|
|
"#3306: carry-forward call must precede the S.messages assignment "
|
|
"in the startGatewaySSE import_cli branch"
|
|
)
|
|
|
|
|
|
def test_carry_forward_call_still_present():
|
|
"""If the #3018 carry-forward is ever removed, this fix becomes moot —
|
|
flag that explicitly so reviewers reconsider the snapshot machinery."""
|
|
body = _ensure_messages_loaded_body().replace(" ", "")
|
|
assert "_carryForwardEphemeralTurnFields" in body, (
|
|
"the #3018 carry-forward is gone — re-evaluate whether "
|
|
"_pendingCarryForwardSnapshot is still needed (#3306)"
|
|
)
|