Files
hermes-webui/tests/test_model_selection_refresh_persistence.py
T
allenliang2022 4ef9d7c5cb fix(webui): render a server-initiated turn that finished during an SSE gap on a visible tab
A visible tab can permanently miss a server-initiated turn (self-wake /
cron / restart hook) when its per-session SSE is momentarily down at the
emit instant. server_turn_started is a fire-and-forget broadcast with no
replay buffer; if the turn also finishes before the tab reconnects, the
on-subscribe self-heal finds no live run in ACTIVE_RUNS and replays
nothing, so the transcript stays stale until a hard refresh.

Extend the self-heal: the (re)subscribing tab reports its last-known
message_count (?known_count); when there is no live run to replay, the
handler compares the persisted count and, if the server is ahead, emits a
lightweight session-updated frame. The frontend syncs incrementally via
the #5189 keepStaleUntilLoaded swap-in-place loadSession path (no
clear+refetch, so the #5177/#5189 blank-gap jump is not reintroduced).
Idle-only and pane-scoped; emits only when the server is strictly ahead.
2026-06-30 17:13:45 +00:00

61 lines
2.6 KiB
Python

"""Regression coverage for model selection surviving hard refresh.
The frontend updates the visible model chip before the async
``/api/session/update`` request returns. A hard refresh can abort that request,
so the browser must remember the session-scoped selection and reapply it on the
next ``loadSession()`` before ``syncTopbar()`` projects server metadata.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BOOT_JS = (ROOT / "static" / "boot.js").read_text()
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text()
UI_JS = (ROOT / "static" / "ui.js").read_text()
def _body_between(src: str, start: str, end: str) -> str:
start_idx = src.index(start)
end_idx = src.index(end, start_idx)
return src[start_idx:end_idx]
def test_model_selection_records_pending_state_before_async_session_update():
"""A refresh during /api/session/update must not lose the selected model."""
body = _body_between(BOOT_JS, "$('modelSelect').onchange=async()=>", "$('msg').addEventListener")
pending_idx = body.index("_rememberPendingSessionModel")
local_model_idx = body.index("S.session.model=modelState.model")
update_idx = body.index("await api('/api/session/update'")
assert pending_idx < update_idx
assert local_model_idx < update_idx
# onchange must NOT clear the pending marker after the session-update round-trip.
# The marker has to survive until the next send() consumes it, otherwise the normal
# pick→update→send flow loses the explicit-pick signal and the server re-reverts a
# cross-family pick (#3737). The consume-clear now lives in send(), not here.
assert "_clearPendingSessionModel" not in body, (
"modelSelect.onchange must not clear the pending explicit-pick marker (#3737); "
"send() consumes it instead"
)
def test_load_session_applies_pending_model_before_first_topbar_sync():
"""Reload should project the pending selection before server old metadata wins."""
body = _body_between(SESSIONS_JS, "async function loadSession", "activeStreamId=S.session.active_stream_id")
apply_idx = body.index("_applyPendingSessionModelForSession")
sync_idx = body.index("syncTopbar()")
assert apply_idx < sync_idx
def test_pending_model_helpers_are_session_scoped_and_expire():
assert "const PENDING_SESSION_MODEL_PREFIX" in UI_JS
assert "function _pendingSessionModelKey" in UI_JS
assert "function _rememberPendingSessionModel" in UI_JS
assert "function _applyPendingSessionModelForSession" in UI_JS
assert "propagateErrors:true" in UI_JS
assert "sessionStorage" in UI_JS
assert "10*60*1000" in UI_JS