mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-19 22:20:30 +00:00
860a3c2458
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).
196 lines
12 KiB
Python
196 lines
12 KiB
Python
from pathlib import Path
|
|
|
|
|
|
SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8")
|
|
UI_JS = Path("static/ui.js").read_text(encoding="utf-8")
|
|
BOOT_JS = Path("static/boot.js").read_text(encoding="utf-8")
|
|
PANELS_JS = Path("static/panels.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_load_session_supports_force_reload_for_external_refresh():
|
|
assert "async function loadSession(sid)" in SESSIONS_JS
|
|
assert "const opts = arguments[1] || {};" in SESSIONS_JS
|
|
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
|
|
assert "if(remoteCount !== localCount)" in SESSIONS_JS
|
|
assert "else if(remoteLast > localLast)" in SESSIONS_JS
|
|
assert "if(S.busy || S.activeStreamId) return;" in SESSIONS_JS
|
|
assert "document.hidden" in SESSIONS_JS
|
|
assert "externalRefreshReason:reason||'poll'" in SESSIONS_JS
|
|
|
|
|
|
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
|
|
assert "await loadSession(sid, {force:true, externalRefreshReason:reason||'poll', keepStaleUntilLoaded:_keepStaleUntilLoaded});" in SESSIONS_JS
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
assert "function ensureSessionEventsSSE()" in SESSIONS_JS
|
|
assert "new EventSource('api/sessions/events')" in SESSIONS_JS
|
|
assert "addEventListener('sessions_changed'" in SESSIONS_JS
|
|
assert "function _scheduleSessionEventsRefresh(reason)" in SESSIONS_JS
|
|
assert "_sessionEventsNeedsRefreshOnOpen = true" in SESSIONS_JS
|
|
assert "void refreshSessionList('reconnect')" in SESSIONS_JS
|
|
assert "renderSessionList({deferWhileInteracting:!force})" in SESSIONS_JS
|
|
assert "const refreshActive = !!(opts && opts.refreshActive)" in SESSIONS_JS
|
|
assert "if(refreshActive) await refreshActiveSessionIfExternallyUpdated(reason||'session-list')" in SESSIONS_JS
|
|
assert "_sessionListRefreshPendingReason = reason || 'session-list'" in SESSIONS_JS
|
|
assert "if(pendingReason) _scheduleSessionEventsRefresh(pendingReason)" in SESSIONS_JS
|
|
assert "ensureSessionEventsSSE();" in SESSIONS_JS
|
|
assert "document._hermesSessionEventsVisibilityHook" in SESSIONS_JS
|
|
ensure_fn = SESSIONS_JS[SESSIONS_JS.find("function ensureSessionEventsSSE()") :]
|
|
# 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")
|
|
assert "_sessionListExternalRefreshMs" not in SESSIONS_JS
|
|
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
|
|
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
|
|
|
|
|
|
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
|
|
assert "const activeProfileState = await _resolveActiveProfileBootstrapState();" in BOOT_JS
|
|
assert "S.activeProfileIsDefault = activeProfileState.isDefault;" in BOOT_JS
|
|
assert "S.activeProfileIsDefault = !!data.is_default;" in PANELS_JS
|
|
|
|
|
|
def test_pwa_pull_to_refresh_refreshes_session_list_not_page_when_available():
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
assert "_restoreComposerDraft(_draft, sid, {preserveActiveInput:!!opts.preserveActiveInput || (currentSid===sid&&forceReload)});" in SESSIONS_JS
|
|
|
|
|
|
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 "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
|
|
assert "const reloadLimitParam = reloadLimit ? `&msg_limit=${reloadLimit}` : '';" in SESSIONS_JS
|
|
assert "finally {\n _clearSameSessionForceReloadHint(sid);\n }" in SESSIONS_JS
|
|
|
|
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()") :]
|
|
|
|
ensure_start = SESSIONS_JS.index("async function _ensureMessagesLoaded(sid")
|
|
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
|