Merge pull request #4514 from nesquena/stage-4424
Release v0.51.526 — unstuck Running indicator when SSE dies during provider retry (#4354)
This commit is contained in:
commit
bd1eae9879
4 changed files with 54 additions and 6 deletions
|
|
@ -3,6 +3,12 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.526] — 2026-06-19 — Release SK (the "Running" indicator clears when the server is idle)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The "Running" indicator no longer stays stuck after the stream dies during a provider retry (#4354).** Three client-side guards keyed on the local `_sendInProgress`/`_sendInProgressSid` flags could keep a session marked busy (and keep a stale `INFLIGHT` entry alive) even after the server reported the run idle. The idle reconciler and the stale-INFLIGHT purge now treat the server's `is_streaming=false` as authoritative, and `loadSession` only asserts `S.busy` when the server confirms an active stream — so a session whose stream died mid-retry stops showing a spinner without needing a reload. Thanks @bergeouss.
|
||||
|
||||
## [v0.51.525] — 2026-06-19 — Release SJ (built-in personalities available in WebUI)
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -381,9 +381,16 @@ function _isServerIdleSessionRow(s) {
|
|||
|
||||
function _reconcileActiveSessionIdleStateFromList(serverRows) {
|
||||
if (!S || !S.session || !S.session.session_id) return false;
|
||||
if (typeof _sendInProgress !== 'undefined' && _sendInProgress) return false;
|
||||
if (!Array.isArray(serverRows)) return false;
|
||||
const sid=S.session.session_id;
|
||||
// #4354: clear a stuck indicator when the server reports idle — server
|
||||
// is_streaming/active_stream_id is authoritative. BUT skip the ONE session
|
||||
// that is actively mid-send (#2689 start-race): during the /api/chat/start
|
||||
// round-trip the server row is still idle while the client owns the optimistic
|
||||
// turn, so reconciling it here would blank the just-sent bubble + queue a
|
||||
// spurious force-reload. A long-hung session has _sendInProgress===false, so
|
||||
// it still gets unstuck — only the in-flight start window is protected.
|
||||
if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) return false;
|
||||
const serverRow=serverRows.find(s=>s&&s.session_id===sid);
|
||||
if (!serverRow) return false;
|
||||
if (!_isServerIdleSessionRow(serverRow)) return false;
|
||||
|
|
@ -435,6 +442,10 @@ function _purgeStaleInflightEntries() {
|
|||
}
|
||||
}
|
||||
for (const sid of Object.keys(INFLIGHT)) {
|
||||
// #4354: purge stale INFLIGHT even for a hung/idle session, BUT skip the one
|
||||
// session actively mid-send (#2689 start-race) — during /api/chat/start the
|
||||
// server row is briefly idle while the client owns the optimistic INFLIGHT
|
||||
// entry; purging it here would drop the in-flight turn's local state.
|
||||
if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1196,7 +1207,7 @@ async function loadSession(sid){
|
|||
}
|
||||
// Refresh todos from cold-load or persisted INFLIGHT before painting.
|
||||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||
S.busy=true;
|
||||
S.busy=!!activeStreamId; // #4354: Only assert busy if server confirms active stream.
|
||||
// appendLiveToolCard() is guarded by S.activeStreamId; restore it before
|
||||
// replaying persisted live tools so the compact Activity count survives
|
||||
// switching away from and back to an active chat (#1715).
|
||||
|
|
|
|||
|
|
@ -39,9 +39,19 @@ def test_send_preserves_optimistic_messages_across_chat_start_await():
|
|||
)
|
||||
|
||||
|
||||
def _strip_js_comments(src: str) -> str:
|
||||
"""Remove // line comments and /* */ block comments so source-grep assertions
|
||||
match real statements, not comment text (a comment must not satisfy a guard
|
||||
regression check). Good enough for these structural checks — not a full JS parser."""
|
||||
import re
|
||||
src = re.sub(r"/\*.*?\*/", "", src, flags=re.DOTALL)
|
||||
src = re.sub(r"(?m)//.*$", "", src)
|
||||
return src
|
||||
|
||||
|
||||
def test_stale_inflight_purge_preserves_current_send_before_stream_id_exists():
|
||||
"""Sidebar cleanup must not delete the active send before /api/chat/start responds."""
|
||||
body = _function_body(SESSIONS_JS, "_purgeStaleInflightEntries")
|
||||
body = _strip_js_comments(_function_body(SESSIONS_JS, "_purgeStaleInflightEntries"))
|
||||
|
||||
assert "_sendInProgress" in body and "_sendInProgressSid" in body, (
|
||||
"_purgeStaleInflightEntries() should skip the current send while start is in progress"
|
||||
|
|
@ -49,6 +59,27 @@ def test_stale_inflight_purge_preserves_current_send_before_stream_id_exists():
|
|||
skip_idx = body.index("_sendInProgress")
|
||||
delete_idx = body.index("delete INFLIGHT[sid];")
|
||||
assert skip_idx < delete_idx, "the current-send skip must run before any purge deletion"
|
||||
# The skip must be a real guarded `continue`, not just a token in a comment (#4354/#2689).
|
||||
assert "continue;" in body[skip_idx:delete_idx], (
|
||||
"the current-send skip must be an actual `continue` before the purge deletion"
|
||||
)
|
||||
|
||||
|
||||
def test_idle_reconcile_preserves_current_send_before_stream_id_exists():
|
||||
"""The list-poll idle reconciler must not clear the session that is mid-send.
|
||||
|
||||
#4354 removed the _sendInProgress guard here to unstick a hung indicator;
|
||||
#2689's start-race protection must still cover the ONE session actively
|
||||
mid-send (server row is briefly idle during /api/chat/start). Verified
|
||||
against comment-stripped source so a comment can't satisfy the check.
|
||||
"""
|
||||
body = _strip_js_comments(_function_body(SESSIONS_JS, "_reconcileActiveSessionIdleStateFromList"))
|
||||
assert "_sendInProgress" in body and "_sendInProgressSid" in body, (
|
||||
"_reconcileActiveSessionIdleStateFromList() must skip the active mid-send session"
|
||||
)
|
||||
guard_idx = body.index("_sendInProgress")
|
||||
clear_idx = body.index("S.busy=false")
|
||||
assert guard_idx < clear_idx, "the mid-send skip must run before the idle clear"
|
||||
|
||||
|
||||
def test_send_clears_stale_busy_state_before_queue_branch():
|
||||
|
|
|
|||
|
|
@ -656,14 +656,14 @@ def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessi
|
|||
inflight_idx = src.rfind("if(INFLIGHT[sid]){")
|
||||
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
|
||||
inflight_block = src[inflight_idx:inflight_idx+4200]
|
||||
busy_pos = inflight_block.find("S.busy=true;")
|
||||
busy_pos = inflight_block.find("S.busy=")
|
||||
# #3326 added an optional {preserveScroll} arg to the INFLIGHT-branch render
|
||||
# call, so match the call form rather than the bare `renderMessages();`.
|
||||
render_pos = inflight_block.find("renderMessages(")
|
||||
assert busy_pos >= 0, "loadSession INFLIGHT branch must set S.busy=true"
|
||||
assert busy_pos >= 0, "loadSession INFLIGHT branch must set S.busy"
|
||||
assert render_pos >= 0, "loadSession INFLIGHT branch must call renderMessages()"
|
||||
assert busy_pos < render_pos, \
|
||||
"loadSession must set S.busy=true before renderMessages() to avoid duplicate tool cards"
|
||||
"loadSession must set S.busy before renderMessages() to avoid duplicate tool cards"
|
||||
|
||||
|
||||
def test_loadSession_inflight_merges_tail_with_persisted_transcript(cleanup_test_sessions):
|
||||
|
|
|
|||
Loading…
Reference in a new issue