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).
88 lines
4.1 KiB
Python
88 lines
4.1 KiB
Python
"""Regression coverage for session-switch busy-state race and live-turn restore.
|
|
|
|
Switching from a streaming session to an idle one must clear S.busy before the
|
|
async _ensureMessagesLoaded gap. Otherwise _isSessionLocallyStreaming() treats
|
|
the newly opened session as locally streaming while messages are still loading.
|
|
|
|
Switching back to a streaming session must restore the snapshotted live turn
|
|
instead of rebuilding thinking/worklog chrome from scratch.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
SESSIONS_SRC = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
UI_SRC = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _function_body(src: str, signature: str) -> str:
|
|
start = src.find(signature)
|
|
assert start != -1, f"missing {signature}"
|
|
brace = src.find("{", start)
|
|
assert brace != -1, f"missing opening brace for {signature}"
|
|
depth = 0
|
|
for i in range(brace, len(src)):
|
|
ch = src[i]
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return src[brace + 1 : i]
|
|
raise AssertionError(f"could not extract function body for {signature}")
|
|
|
|
|
|
def test_loadSession_clears_busy_before_async_message_load_when_server_idle():
|
|
body = _function_body(SESSIONS_SRC, "async function loadSession(")
|
|
|
|
idle_reset = body.find("if(!activeStreamId){")
|
|
assert idle_reset != -1, "loadSession must gate idle cleanup on missing active_stream_id"
|
|
idle_block = body[idle_reset : idle_reset + 500]
|
|
assert "S.busy=false" in idle_block, "idle switch must clear S.busy immediately"
|
|
assert "S.activeStreamId=null" in idle_block, "idle switch must clear S.activeStreamId immediately"
|
|
|
|
ensure_load = body.find("await _ensureMessagesLoaded(sid")
|
|
assert ensure_load != -1, "loadSession must still lazy-load messages for idle sessions"
|
|
assert idle_reset < ensure_load, (
|
|
"S.busy must be cleared before _ensureMessagesLoaded so session-list polling "
|
|
"during the async gap does not mark the new session as locally streaming"
|
|
)
|
|
|
|
|
|
def test_loadSession_snapshots_live_turn_before_wiping_message_pane():
|
|
body = _function_body(SESSIONS_SRC, "async function loadSession(")
|
|
|
|
snap_pos = body.find("snapshotLiveTurnHtmlForSession(currentSid)")
|
|
# Anchor on the actual loading-placeholder marker (unique), not the
|
|
# whitespace-sensitive innerHTML literal which also matches the
|
|
# "Session not available" error handler. (Maintainer review.)
|
|
wipe_pos = body.find("Loading conversation...")
|
|
assert snap_pos != -1, "loadSession must snapshot the outgoing live turn before switching"
|
|
assert wipe_pos != -1, "loadSession must still show the loading placeholder on switch"
|
|
assert snap_pos < wipe_pos, "snapshot must run before msgInner is replaced with the loading placeholder"
|
|
|
|
|
|
def test_loadSession_restores_live_turn_on_active_stream_return_path():
|
|
body = _function_body(SESSIONS_SRC, "async function loadSession(")
|
|
|
|
# The restore that actually fires on switch-back is the Phase 2a path: after
|
|
# loadInflightState() rehydrates INFLIGHT for an active stream, the streaming
|
|
# branch calls restoreLiveTurnHtmlForSession(sid). (The old Phase-2b idle-branch
|
|
# call was unreachable — INFLIGHT is always seeded by then — so assert the live
|
|
# Phase 2a path. Maintainer review.)
|
|
phase2a = body.find("Phase 2a")
|
|
assert phase2a != -1, "loadSession must keep the Phase 2a streaming-restore branch"
|
|
inflight_load = body.find("loadInflightState(sid", phase2a)
|
|
assert inflight_load != -1, "Phase 2a must rehydrate INFLIGHT from persisted state for an active stream"
|
|
restore = body.find("restoreLiveTurnHtmlForSession(sid)", inflight_load)
|
|
assert restore != -1, (
|
|
"the active-stream return path must restore the snapshotted live-turn HTML "
|
|
"after rehydrating INFLIGHT (Phase 2a), instead of rebuilding the worklog shell"
|
|
)
|
|
|
|
|
|
def test_activity_timer_reads_pending_started_at():
|
|
body = _function_body(UI_SRC, "function _activityElapsedStartedAt(")
|
|
assert "pending_started_at" in body
|
|
assert "data-turn-started-at" in body or "turnStartedAt" in body
|