mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
fix(#3900): false streaming + activity-timer reset on session switch (absorb #3899) + re-anchor regression tests
Production fixes (Tamaz-sujashvili, reviewed sound by maintainer): - loadSession clears S.busy/S.activeStreamId as soon as metadata confirms no active_stream_id, before the async message-load gap (idle session no longer shows streaming chrome). - Snapshots the live turn before wiping msgInner + seeds INFLIGHT, restores on the active-stream return path (timer/trace survive switch-back). Re-anchored the 2 brittle regression tests per maintainer review: test_..snapshots.. now anchors on the unique 'Loading conversation...' marker (was matching the no-space 'Session not available' error path); test_..restores.. now asserts the LIVE Phase 2a restore (after loadInflightState) instead of the unreachable Phase-2b/1184 branch. CHANGELOG stamped v0.51.384 (MW).
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.384] — 2026-06-13 — Release MW (no false streaming / activity-timer reset on session switch, #3900)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **An idle session no longer shows streaming chrome (Stop/spinner/thinking) right after a sidebar switch, and switching back to a live stream no longer resets its activity timer (#3900).** `loadSession` now clears `S.busy` / `S.activeStreamId` as soon as the session metadata confirms there is no `active_stream_id` — before the async message-load gap — so the previous session's busy flag can't bleed onto an idle chat. On switch-away it snapshots the live-turn DOM before replacing the message pane (seeding an `INFLIGHT` bucket if needed) and restores that HTML on the active-stream return path instead of rebuilding the worklog shell from scratch, so the elapsed timer and live trace survive the round-trip. (#3900)
|
||||
|
||||
## [v0.51.383] — 2026-06-13 — Release MV (desktop tab title keeps active session name on bot-name refresh, #4086)
|
||||
|
||||
### Fixed
|
||||
@@ -265,6 +271,7 @@
|
||||
|
||||
- **Worklog details settings now align with the live-to-final model.** The old "Activity expanded by default" setting is renamed to **Worklog details** (default folded), the legacy "Compact tool activity" preference is deprecated, and the Worklog renderer stays enabled for older installs that had saved `simplified_tool_calling=false`. (#3400, #3820)
|
||||
|
||||
|
||||
## [v0.51.346] — 2026-06-09 — Release LJ (PWA notification controls)
|
||||
|
||||
### Added
|
||||
|
||||
+37
-9
@@ -878,6 +878,22 @@ async function loadSession(sid){
|
||||
// close streams for the session the user actually landed on (#1060 guard,
|
||||
// extended to cover the new pre-switch await).
|
||||
if (_loadingSessionId !== sid) return;
|
||||
// Snapshot the live turn before msgInner is replaced. Preserves the activity
|
||||
// timer, partial response, and tool cards so switching back does not rebuild
|
||||
// the stream UI from scratch.
|
||||
if(
|
||||
(S.busy||S.activeStreamId||(INFLIGHT&&INFLIGHT[currentSid]))&&
|
||||
typeof snapshotLiveTurnHtmlForSession==='function'
|
||||
){
|
||||
if(!INFLIGHT[currentSid]){
|
||||
INFLIGHT[currentSid]={
|
||||
messages:Array.isArray(S.messages)?[...S.messages]:[],
|
||||
uploaded:[],
|
||||
toolCalls:Array.isArray(S.toolCalls)?[...S.toolCalls]:[],
|
||||
};
|
||||
}
|
||||
snapshotLiveTurnHtmlForSession(currentSid);
|
||||
}
|
||||
}
|
||||
if (currentSid !== sid || forceReload) {
|
||||
// #3306: When force-reloading the currently-active session (e.g. external
|
||||
@@ -1040,14 +1056,21 @@ async function loadSession(sid){
|
||||
if(typeof startSessionStream==='function') startSessionStream(S.session.session_id);
|
||||
|
||||
const activeStreamId=S.session.active_stream_id||null;
|
||||
// If the server says the session is idle, discard any browser-side inflight
|
||||
// cache left behind by a crashed/restarted stream. Otherwise the UI can keep
|
||||
// showing a permanent thinking/running state even though active_streams=0.
|
||||
if(!activeStreamId&&INFLIGHT[sid]){
|
||||
delete INFLIGHT[sid];
|
||||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||||
// If the server says the session is idle, reset browser-side streaming flags
|
||||
// NOW — before the async _ensureMessagesLoaded gap below. Without this,
|
||||
// S.busy can remain true from a still-running stream in the PREVIOUS session
|
||||
// while S.session.session_id has already advanced to the new one.
|
||||
// _isSessionLocallyStreaming() checks (isActive && S.busy), so during the
|
||||
// async window the new session would appear locally-streaming (sidebar spinner,
|
||||
// Stop button, thinking state on an idle chat). Also clears stale INFLIGHT
|
||||
// entries left behind by a crashed/restarted stream.
|
||||
if(!activeStreamId){
|
||||
S.activeStreamId=null;
|
||||
S.busy=false;
|
||||
if(INFLIGHT[sid]){
|
||||
delete INFLIGHT[sid];
|
||||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||||
}
|
||||
}
|
||||
|
||||
function _mergePendingSessionMessage(session,messages){
|
||||
@@ -1262,10 +1285,15 @@ async function loadSession(sid){
|
||||
updateSendBtn();
|
||||
setStatus('');
|
||||
setComposerStatus('');
|
||||
// syncTopbar();renderMessages();appendThinking();loadDir('.');
|
||||
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
|
||||
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
|
||||
else appendThinking();
|
||||
let restoredLiveTurn=false;
|
||||
if(typeof restoreLiveTurnHtmlForSession==='function'){
|
||||
restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);
|
||||
}
|
||||
if(!restoredLiveTurn){
|
||||
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
|
||||
else appendThinking();
|
||||
}
|
||||
loadDir('.');
|
||||
updateQueueBadge(sid);
|
||||
startApprovalPolling(sid);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user