269 lines
16 KiB
Python
269 lines
16 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_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
|
|
|
|
|
|
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_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.
|
|
"""
|
|
start = SESSIONS_JS.find("function _sessionListRenderSignature()")
|
|
assert start != -1
|
|
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
|
|
|
|
|
|
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
|
|
body = SESSIONS_JS[start:start + 12000]
|
|
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;")
|
|
|
|
|
|
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
|