fix(webui): render server-initiated turns in a hidden tab without a refresh
A turn started SERVER-SIDE (self-wake, cron, restart hook) fans a `server_turn_started` frame onto the per-session live-view SSE channel so an open tab renders it live. But while a tab is HIDDEN the WebUI deliberately does NOT hold that persistent SSE open (connection-pool budget — #3992 / #4151), so `get_session_channel()` returns None at fan-out time and the turn is dropped for that tab. The user only saw the turn after a manual interaction (refresh / send) re-subscribed the channel and replayed it. Bridge the gap with a lightweight poll of `/api/session/status` (one short GET per ~6s tick — NOT a held connection, so the connection-pool budget the hidden guard protects is preserved) that attaches the existing live renderer when it sees a *live* active stream. Backend (`api/session_ops.py`): - `session_status()` now exposes `active_stream_id`, derived through a new `_live_active_stream_id()` helper that only returns the id when the stream is genuinely live (present in STREAMS / ACTIVE_RUNS). A stale id left by a crashed/restarted run surfaces as None, so the poller never attaches a renderer to a dead stream. Additive field — existing consumers ignore it. Frontend (`static/messages.js`): - `_startHiddenActiveStreamPoll` / `_stopHiddenActiveStreamPoll` / `_attachServerInitiatedStream` implement the poll lifecycle. The poll fires one immediate tick on hide (so an in-flight turn is caught without waiting a full interval), attaches mid-flight turns via the reconnecting/replay path, and stops on re-show (the real SSE takes over), session switch, or once a stream is rendering. - Started on BOTH hidden paths: the `visibilitychange` hook's hidden branch (a visible tab going to background) and `startSessionStream`'s hidden early-return skip (a session opened while already hidden). The visible path and `stopSessionStream` clear it. Tests: - `tests/test_hidden_tab_server_initiated_turn.py` — backend live-validation (stale id → None; STREAMS/ACTIVE_RUNS id returned) and frontend lifecycle / both-hidden-paths / replay-attach source locks. - Widened the brittle fixed-width source-window slices in `tests/test_issue3996_sse_visibility.py` (1600/1700 → 2400) so the existing `visibilitychange` / hidden-skip assertions still find their markers after the poll-start lines were inserted into `startSessionStream`.
This commit is contained in:
1 parent
8cee867f19
commit
ada8c262ca
4 files changed
+280
-2
No files matched your search
@@ -17,6 +17,36 @@ logger = logging.getLogger(__name__)
|
||||
AUTO_TITLE_LABELS = {'untitled', 'new chat'}
|
||||
|
||||
|
||||
def _live_active_stream_id(session) -> str | None:
|
||||
"""Return session.active_stream_id ONLY if that stream is live in THIS
|
||||
process; else None.
|
||||
|
||||
After a restart/crash the persisted active_stream_id survives in the
|
||||
session JSON but the in-memory STREAMS / ACTIVE_RUNS that actually drive a
|
||||
live turn were wiped. Exposing that dead id (e.g. via /api/session/status to
|
||||
the hidden-tab poller) would make a client attach its renderer to a stream
|
||||
that never emits — a permanent fake "thinking" state. Liveness test mirrors
|
||||
routes._clear_stale_stream_state: live iff present in STREAMS (open SSE
|
||||
channel) or ACTIVE_RUNS (worker bookkeeping).
|
||||
"""
|
||||
stream_id = getattr(session, 'active_stream_id', None)
|
||||
if not stream_id:
|
||||
return None
|
||||
try:
|
||||
from api import config as _cfg
|
||||
with _cfg.STREAMS_LOCK:
|
||||
if stream_id in _cfg.STREAMS:
|
||||
return stream_id
|
||||
with _cfg.ACTIVE_RUNS_LOCK:
|
||||
if stream_id in (_cfg.ACTIVE_RUNS or {}):
|
||||
return stream_id
|
||||
except Exception:
|
||||
# On any introspection failure, fail SAFE (report no live stream) rather
|
||||
# than surfacing a possibly-stale id.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def session_has_manual_title(session) -> bool:
|
||||
"""Return whether adaptive title refresh should leave this title alone."""
|
||||
return getattr(session, 'manual_title', False) is True
|
||||
@@ -242,6 +272,22 @@ def session_status(session_id: str) -> dict[str, Any]:
|
||||
'created_at': s.created_at,
|
||||
'updated_at': s.updated_at,
|
||||
'agent_running': bool(getattr(s, 'active_stream_id', None)),
|
||||
# Expose the stream id itself (not just the agent_running bool) so a
|
||||
# hidden-tab poller can attach the live renderer to a server-initiated
|
||||
# turn (self-wake / cron / restart hook) without opening the persistent
|
||||
# per-session SSE while the tab is hidden. See messages.js hidden-tab
|
||||
# active-stream poll. Additive field — existing consumers ignore it.
|
||||
#
|
||||
# CRITICAL: only expose a stream id that is actually LIVE in this
|
||||
# process. After a restart/crash the persisted active_stream_id is stale
|
||||
# (the in-memory STREAMS/ACTIVE_RUNS were wiped) — handing that dead id
|
||||
# to the poller would make it attach a renderer to a stream that never
|
||||
# produces tokens (a permanent fake "thinking" state). Mirror
|
||||
# _clear_stale_stream_state's liveness test: a stream counts as live
|
||||
# only if it's in STREAMS (SSE channel open) or ACTIVE_RUNS (worker
|
||||
# bookkeeping). Otherwise report None so the poller waits for a REAL
|
||||
# server_turn_started instead of latching a ghost.
|
||||
'active_stream_id': _live_active_stream_id(s),
|
||||
'input_tokens': inp,
|
||||
'output_tokens': out,
|
||||
'total_tokens': inp + out,
|
||||
|
||||
Reference in new issue
Block a user