Skip to content

Commit abe89f3

Browse files
Merge pull request nesquena#4104 from nesquena/stage-3899
Release MW (v0.51.384): no false streaming / activity-timer reset on session switch (nesquena#3900)
2 parents 0f9b623 + e129203 commit abe89f3

8 files changed

Lines changed: 180 additions & 22 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33

44
## [Unreleased]
55

6+
## [v0.51.384] — 2026-06-13 — Release MW (no false streaming / activity-timer reset on session switch, #3900)
7+
8+
### Fixed
9+
10+
- **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)
11+
612
## [v0.51.383] — 2026-06-13 — Release MV (desktop tab title keeps active session name on bot-name refresh, #4086)
713

814
### Fixed
@@ -265,6 +271,7 @@
265271

266272
- **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)
267273

274+
268275
## [v0.51.346] — 2026-06-09 — Release LJ (PWA notification controls)
269276

270277
### Added

static/sessions.js

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,22 @@ async function loadSession(sid){
878878
// close streams for the session the user actually landed on (#1060 guard,
879879
// extended to cover the new pre-switch await).
880880
if (_loadingSessionId !== sid) return;
881+
// Snapshot the live turn before msgInner is replaced. Preserves the activity
882+
// timer, partial response, and tool cards so switching back does not rebuild
883+
// the stream UI from scratch.
884+
if(
885+
(S.busy||S.activeStreamId||(INFLIGHT&&INFLIGHT[currentSid]))&&
886+
typeof snapshotLiveTurnHtmlForSession==='function'
887+
){
888+
if(!INFLIGHT[currentSid]){
889+
INFLIGHT[currentSid]={
890+
messages:Array.isArray(S.messages)?[...S.messages]:[],
891+
uploaded:[],
892+
toolCalls:Array.isArray(S.toolCalls)?[...S.toolCalls]:[],
893+
};
894+
}
895+
snapshotLiveTurnHtmlForSession(currentSid);
896+
}
881897
}
882898
if (currentSid !== sid || forceReload) {
883899
// #3306: When force-reloading the currently-active session (e.g. external
@@ -1040,14 +1056,21 @@ async function loadSession(sid){
10401056
if(typeof startSessionStream==='function') startSessionStream(S.session.session_id);
10411057

10421058
const activeStreamId=S.session.active_stream_id||null;
1043-
// If the server says the session is idle, discard any browser-side inflight
1044-
// cache left behind by a crashed/restarted stream. Otherwise the UI can keep
1045-
// showing a permanent thinking/running state even though active_streams=0.
1046-
if(!activeStreamId&&INFLIGHT[sid]){
1047-
delete INFLIGHT[sid];
1048-
if(typeof clearInflightState==='function') clearInflightState(sid);
1059+
// If the server says the session is idle, reset browser-side streaming flags
1060+
// NOW — before the async _ensureMessagesLoaded gap below. Without this,
1061+
// S.busy can remain true from a still-running stream in the PREVIOUS session
1062+
// while S.session.session_id has already advanced to the new one.
1063+
// _isSessionLocallyStreaming() checks (isActive && S.busy), so during the
1064+
// async window the new session would appear locally-streaming (sidebar spinner,
1065+
// Stop button, thinking state on an idle chat). Also clears stale INFLIGHT
1066+
// entries left behind by a crashed/restarted stream.
1067+
if(!activeStreamId){
10491068
S.activeStreamId=null;
10501069
S.busy=false;
1070+
if(INFLIGHT[sid]){
1071+
delete INFLIGHT[sid];
1072+
if(typeof clearInflightState==='function') clearInflightState(sid);
1073+
}
10511074
}
10521075

10531076
function _mergePendingSessionMessage(session,messages){
@@ -1262,10 +1285,15 @@ async function loadSession(sid){
12621285
updateSendBtn();
12631286
setStatus('');
12641287
setComposerStatus('');
1265-
// syncTopbar();renderMessages();appendThinking();loadDir('.');
12661288
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
1267-
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
1268-
else appendThinking();
1289+
let restoredLiveTurn=false;
1290+
if(typeof restoreLiveTurnHtmlForSession==='function'){
1291+
restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);
1292+
}
1293+
if(!restoredLiveTurn){
1294+
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
1295+
else appendThinking();
1296+
}
12691297
loadDir('.');
12701298
updateQueueBadge(sid);
12711299
startApprovalPolling(sid);

tests/test_inflight_stream_reuse.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,10 @@ def test_load_session_reattaches_when_inflight_is_in_memory_and_marked_for_reatt
254254
pins the gate's shape so future refactors don't drop the flag check.
255255
"""
256256
body = _function_body(SESSIONS_JS, "loadSession")
257-
inflight_idx = body.find("if(INFLIGHT[sid]){")
257+
# Anchor on the Phase-2 INFLIGHT restore branch (the later occurrence): #3899
258+
# added an earlier if(INFLIGHT[sid]){ idle-reset block, so .find() would grab
259+
# the wrong one. rfind = the substantive restore branch.
260+
inflight_idx = body.rfind("if(INFLIGHT[sid]){")
258261
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
259262
inflight_block = body[inflight_idx : inflight_idx + 4200]
260263
assert "INFLIGHT[sid].reattach" in inflight_block, (
@@ -283,9 +286,17 @@ def test_load_session_attaches_sse_before_auxiliary_work():
283286
active_branch = body[body.find("if(activeStreamId){") : body.find("}else{", body.find("if(activeStreamId){"))]
284287
active_attach = active_branch.find("attachLiveStream(sid, activeStreamId")
285288
assert active_attach != -1
289+
# #3899 inserted restoreLiveTurnHtmlForSession between renderMessages and
290+
# appendThinking, and renderMessages now takes a preserveScroll arg — so the
291+
# old contiguous "syncTopbar();renderMessages();appendThinking();loadDir('.');"
292+
# literal no longer exists. Assert each auxiliary call individually; all must
293+
# still run AFTER attachLiveStream (the invariant this test protects).
286294
for marker in (
287295
"updateSendBtn();",
288-
"syncTopbar();renderMessages();appendThinking();loadDir('.');",
296+
"syncTopbar();",
297+
"renderMessages(",
298+
"appendThinking();",
299+
"loadDir('.');",
289300
"updateQueueBadge(sid);",
290301
"startApprovalPolling(sid)",
291302
):
@@ -1013,7 +1024,9 @@ def test_load_session_discards_cursor_only_inflight_before_reattach():
10131024
guard = "if(activeStreamId&&INFLIGHT[sid]&&!_inflightHasVisibleLiveState(INFLIGHT[sid]))"
10141025
assert guard in compact_load
10151026
guard_pos = compact_load.find(guard)
1016-
inflight_branch_pos = compact_load.find("if(INFLIGHT[sid]){")
1027+
# rfind: anchor on the Phase-2 restore branch, not #3899's earlier idle-reset
1028+
# if(INFLIGHT[sid]){ block (which now precedes the guard).
1029+
inflight_branch_pos = compact_load.rfind("if(INFLIGHT[sid]){")
10171030
assert 0 <= guard_pos < inflight_branch_pos
10181031

10191032

tests/test_issue3306_loadsession_carry_forward.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
def _load_session_clear_block() -> str:
2727
"""The if(currentSid!==sid||forceReload){...} block in loadSession()."""
2828
start = SESSIONS_JS.index("async function loadSession(sid)")
29-
return SESSIONS_JS[start: start + 4000]
29+
# Window widened to 6500: #3899's idle-reset + live-turn-snapshot blocks added
30+
# code earlier in loadSession, pushing the carry-forward snapshot past the old
31+
# 4000-char window.
32+
return SESSIONS_JS[start: start + 6500]
3033

3134

3235
def _ensure_messages_loaded_body() -> str:

tests/test_issue_new_chat_draft_restore.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ def test_pre_switch_draft_flush_rechecks_stale_loading_guard():
7676
(Codex pre-release CORE catch, #3471)."""
7777
start = SESSIONS_JS.find("async function loadSession(")
7878
assert start != -1, "loadSession not found"
79-
body = SESSIONS_JS[start:start + 4000]
79+
# Window widened to 6500: #3899's idle-reset + live-turn-snapshot blocks pushed
80+
# the destructive S.messages clear past the old 4000-char window.
81+
body = SESSIONS_JS[start:start + 6500]
8082
await_idx = body.find("await _saveComposerDraftNow(currentSid")
8183
guard_idx = body.find("if (_loadingSessionId !== sid) return;", await_idx)
8284
clear_idx = body.find("S.messages = [];", await_idx)

tests/test_regressions.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,10 @@ def test_loadSession_inflight_restores_live_tool_cards(cleanup_test_sessions):
426426
"""
427427
src = (REPO_ROOT / "static/sessions.js").read_text()
428428
# INFLIGHT branch must call appendLiveToolCard
429-
inflight_idx = src.find("if(INFLIGHT[sid]){")
429+
# Anchor on the Phase-2 INFLIGHT restore branch (the later occurrence); #3899
430+
# added an earlier if(INFLIGHT[sid]){ idle-reset block, so .find() would
431+
# grab the wrong one. (rfind = the substantive restore branch.)
432+
inflight_idx = src.rfind("if(INFLIGHT[sid]){")
430433
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
431434
inflight_block = src[inflight_idx:inflight_idx+4200]
432435
assert "appendLiveToolCard" in inflight_block, "loadSession INFLIGHT branch must restore live tool cards via appendLiveToolCard"
@@ -647,7 +650,10 @@ def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessi
647650
session switch.
648651
"""
649652
src = (REPO_ROOT / "static/sessions.js").read_text()
650-
inflight_idx = src.find("if(INFLIGHT[sid]){")
653+
# Anchor on the Phase-2 INFLIGHT restore branch (the later occurrence); #3899
654+
# added an earlier if(INFLIGHT[sid]){ idle-reset block, so .find() would
655+
# grab the wrong one. (rfind = the substantive restore branch.)
656+
inflight_idx = src.rfind("if(INFLIGHT[sid]){")
651657
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
652658
inflight_block = src[inflight_idx:inflight_idx+4200]
653659
busy_pos = inflight_block.find("S.busy=true;")
@@ -662,7 +668,10 @@ def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessi
662668

663669
def test_loadSession_inflight_merges_tail_with_persisted_transcript(cleanup_test_sessions):
664670
src = (REPO_ROOT / "static/sessions.js").read_text()
665-
inflight_idx = src.find("if(INFLIGHT[sid]){")
671+
# Anchor on the Phase-2 INFLIGHT restore branch (the later occurrence); #3899
672+
# added an earlier if(INFLIGHT[sid]){ idle-reset block, so .find() would
673+
# grab the wrong one. (rfind = the substantive restore branch.)
674+
inflight_idx = src.rfind("if(INFLIGHT[sid]){")
666675
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
667676
inflight_block = src[inflight_idx:inflight_idx+1200]
668677

@@ -756,7 +765,10 @@ def test_loadSession_inflight_sets_active_stream_before_replaying_live_tool_card
756765
counter drops the previously-seen tools after a focus change.
757766
"""
758767
src = (REPO_ROOT / "static/sessions.js").read_text()
759-
inflight_idx = src.find("if(INFLIGHT[sid]){")
768+
# Anchor on the Phase-2 INFLIGHT restore branch (the later occurrence); #3899
769+
# added an earlier if(INFLIGHT[sid]){ idle-reset block, so .find() would
770+
# grab the wrong one. (rfind = the substantive restore branch.)
771+
inflight_idx = src.rfind("if(INFLIGHT[sid]){")
760772
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
761773
inflight_block = src[inflight_idx:inflight_idx+4200]
762774
active_pos = inflight_block.find("S.activeStreamId=activeStreamId;")
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Regression coverage for session-switch busy-state race and live-turn restore.
2+
3+
Switching from a streaming session to an idle one must clear S.busy before the
4+
async _ensureMessagesLoaded gap. Otherwise _isSessionLocallyStreaming() treats
5+
the newly opened session as locally streaming while messages are still loading.
6+
7+
Switching back to a streaming session must restore the snapshotted live turn
8+
instead of rebuilding thinking/worklog chrome from scratch.
9+
"""
10+
11+
from pathlib import Path
12+
13+
REPO = Path(__file__).resolve().parents[1]
14+
SESSIONS_SRC = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
15+
UI_SRC = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
16+
17+
18+
def _function_body(src: str, signature: str) -> str:
19+
start = src.find(signature)
20+
assert start != -1, f"missing {signature}"
21+
brace = src.find("{", start)
22+
assert brace != -1, f"missing opening brace for {signature}"
23+
depth = 0
24+
for i in range(brace, len(src)):
25+
ch = src[i]
26+
if ch == "{":
27+
depth += 1
28+
elif ch == "}":
29+
depth -= 1
30+
if depth == 0:
31+
return src[brace + 1 : i]
32+
raise AssertionError(f"could not extract function body for {signature}")
33+
34+
35+
def test_loadSession_clears_busy_before_async_message_load_when_server_idle():
36+
body = _function_body(SESSIONS_SRC, "async function loadSession(")
37+
38+
idle_reset = body.find("if(!activeStreamId){")
39+
assert idle_reset != -1, "loadSession must gate idle cleanup on missing active_stream_id"
40+
idle_block = body[idle_reset : idle_reset + 500]
41+
assert "S.busy=false" in idle_block, "idle switch must clear S.busy immediately"
42+
assert "S.activeStreamId=null" in idle_block, "idle switch must clear S.activeStreamId immediately"
43+
44+
ensure_load = body.find("await _ensureMessagesLoaded(sid)")
45+
assert ensure_load != -1, "loadSession must still lazy-load messages for idle sessions"
46+
assert idle_reset < ensure_load, (
47+
"S.busy must be cleared before _ensureMessagesLoaded so session-list polling "
48+
"during the async gap does not mark the new session as locally streaming"
49+
)
50+
51+
52+
def test_loadSession_snapshots_live_turn_before_wiping_message_pane():
53+
body = _function_body(SESSIONS_SRC, "async function loadSession(")
54+
55+
snap_pos = body.find("snapshotLiveTurnHtmlForSession(currentSid)")
56+
# Anchor on the actual loading-placeholder marker (unique), not the
57+
# whitespace-sensitive innerHTML literal which also matches the
58+
# "Session not available" error handler. (Maintainer review.)
59+
wipe_pos = body.find("Loading conversation...")
60+
assert snap_pos != -1, "loadSession must snapshot the outgoing live turn before switching"
61+
assert wipe_pos != -1, "loadSession must still show the loading placeholder on switch"
62+
assert snap_pos < wipe_pos, "snapshot must run before msgInner is replaced with the loading placeholder"
63+
64+
65+
def test_loadSession_restores_live_turn_on_active_stream_return_path():
66+
body = _function_body(SESSIONS_SRC, "async function loadSession(")
67+
68+
# The restore that actually fires on switch-back is the Phase 2a path: after
69+
# loadInflightState() rehydrates INFLIGHT for an active stream, the streaming
70+
# branch calls restoreLiveTurnHtmlForSession(sid). (The old Phase-2b idle-branch
71+
# call was unreachable — INFLIGHT is always seeded by then — so assert the live
72+
# Phase 2a path. Maintainer review.)
73+
phase2a = body.find("Phase 2a")
74+
assert phase2a != -1, "loadSession must keep the Phase 2a streaming-restore branch"
75+
inflight_load = body.find("loadInflightState(sid", phase2a)
76+
assert inflight_load != -1, "Phase 2a must rehydrate INFLIGHT from persisted state for an active stream"
77+
restore = body.find("restoreLiveTurnHtmlForSession(sid)", inflight_load)
78+
assert restore != -1, (
79+
"the active-stream return path must restore the snapshotted live-turn HTML "
80+
"after rehydrating INFLIGHT (Phase 2a), instead of rebuilding the worklog shell"
81+
)
82+
83+
84+
def test_activity_timer_reads_pending_started_at():
85+
body = _function_body(UI_SRC, "function _activityElapsedStartedAt(")
86+
assert "pending_started_at" in body
87+
assert "data-turn-started-at" in body or "turnStartedAt" in body

tests/test_stale_stream_cleanup.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -398,13 +398,19 @@ def start_new_stream():
398398

399399

400400
def test_frontend_drops_inflight_cache_when_server_session_is_idle():
401-
marker = "If the server says the session is idle, discard any browser-side inflight"
401+
# #3900/#3899 generalized this block: on an idle server session it now resets
402+
# the streaming flags (S.busy/S.activeStreamId) AND drops the inflight cache,
403+
# before the async message-load gap. Anchor on the current comment + assert the
404+
# (preserved) cache-drop behavior in the now-nested form.
405+
marker = "If the server says the session is idle, reset browser-side streaming flags"
402406
marker_pos = SESSIONS_SRC.index(marker)
403-
window = SESSIONS_SRC[marker_pos:marker_pos + 500]
404-
assert "if(!activeStreamId&&INFLIGHT[sid])" in window
407+
window = SESSIONS_SRC[marker_pos:marker_pos + 900]
408+
assert "if(!activeStreamId){" in window
409+
assert "S.busy=false" in window
410+
assert "S.activeStreamId=null" in window
411+
assert "if(INFLIGHT[sid]){" in window
405412
assert "delete INFLIGHT[sid]" in window
406413
assert "clearInflightState" in window
407-
assert "S.busy=false" in window
408414

409415

410416
def test_service_worker_cache_bumped_for_frontend_fix_delivery():

0 commit comments

Comments
 (0)