diff --git a/CHANGELOG.md b/CHANGELOG.md index 19652a10..c9568a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.430] — 2026-06-15 — Release OQ (session-list-changed events carry the changed session id) + +### Changed + +- **Session-list invalidation events can now carry the specific `session_id` that changed.** `publish_session_list_changed(...)` and the `/api/sessions/events` SSE payload gained an optional `session_id` (defaulting to none, so the broad-refresh behavior is unchanged and backward-compatible). The frontend uses it to tell whether an event targets the currently-open session, refining the refresh signal instead of always doing a broad sidebar refresh. Profile scoping and the legacy one-argument publish shape are preserved. (#4221) + ## [v0.51.429] — 2026-06-15 — Release OP (preserve multiple messageful imported/CLI sessions in the sidebar) ### Fixed diff --git a/api/routes.py b/api/routes.py index de32575c..23888603 100644 --- a/api/routes.py +++ b/api/routes.py @@ -43,17 +43,28 @@ from api.session_events import ( logger = logging.getLogger(__name__) -def _publish_session_list_changed(reason: str, *, profile: str | None = None) -> None: - """Publish profile-scoped session changes while tolerating legacy test doubles.""" - if not profile: +def _publish_session_list_changed( + reason: str, + *, + profile: str | None = None, + session_id: str | None = None, +) -> None: + """Publish scoped session changes while tolerating legacy test doubles.""" + if not profile and not session_id: publish_session_list_changed(reason) return try: - publish_session_list_changed(reason, profile=profile) + publish_session_list_changed(reason, profile=profile, session_id=session_id) except TypeError: # Some focused tests monkeypatch the route-level publisher with the - # historical one-argument shape. Preserve the old signal instead of + # historical one-argument or profile-only shape. Preserve the old signal instead of # turning unrelated session mutations into 500s. + if profile: + try: + publish_session_list_changed(reason, profile=profile) + return + except TypeError: + pass publish_session_list_changed(reason) @@ -126,7 +137,11 @@ def _persist_generated_session_title( while len(SESSIONS) > SESSIONS_MAX: SESSIONS.popitem(last=False) _sync_session_title_to_insights(session) - _publish_session_list_changed(event_reason, profile=getattr(session, "profile", None)) + _publish_session_list_changed( + event_reason, + profile=getattr(session, "profile", None), + session_id=sid, + ) if original_session is not session: original_session.title = session.title original_session.llm_title_generated = session.llm_title_generated @@ -8005,7 +8020,11 @@ def handle_post(handler, parsed) -> bool: worktree_info=worktree_info, ) if worktree_info: - publish_session_list_changed("session_new", profile=getattr(s, "profile", None)) + publish_session_list_changed( + "session_new", + profile=getattr(s, "profile", None), + session_id=getattr(s, "session_id", None), + ) return j(handler, {"session": s.compact() | {"messages": s.messages}}) if parsed.path == "/api/session/duplicate": @@ -8087,7 +8106,11 @@ def handle_post(handler, parsed) -> bool: # Without this explicit save, the duplicate is in-memory only — if the user # refreshes before sending a turn, the duplicate vanishes. copied_session.save() - publish_session_list_changed("session_duplicate", profile=getattr(copied_session, "profile", None)) + publish_session_list_changed( + "session_duplicate", + profile=getattr(copied_session, "profile", None), + session_id=getattr(copied_session, "session_id", None), + ) return j(handler, {"session": copied_session.compact() | {"messages": copied_session.messages}}) except Exception as e: @@ -8222,7 +8245,11 @@ def handle_post(handler, parsed) -> bool: apply_session_title_rename(s, body["title"]) s.save() _sync_session_title_to_insights(s) - publish_session_list_changed("session_rename", profile=getattr(s, "profile", None)) + publish_session_list_changed( + "session_rename", + profile=getattr(s, "profile", None), + session_id=getattr(s, "session_id", body["session_id"]), + ) return j(handler, {"session": s.compact()}) @@ -8718,7 +8745,11 @@ def handle_post(handler, parsed) -> bool: # Persist only if there are messages (matches new_session pattern) if forked_messages: branch.save() - publish_session_list_changed("session_branch", profile=getattr(branch, "profile", None)) + publish_session_list_changed( + "session_branch", + profile=getattr(branch, "profile", None), + session_id=getattr(branch, "session_id", None), + ) return j(handler, { "session_id": branch.session_id, @@ -9310,7 +9341,11 @@ def handle_post(handler, parsed) -> bool: with _get_session_agent_lock(body["session_id"]): s.pinned = pin_requested s.save() - publish_session_list_changed("session_pin", profile=getattr(s, "profile", None)) + publish_session_list_changed( + "session_pin", + profile=getattr(s, "profile", None), + session_id=getattr(s, "session_id", body["session_id"]), + ) return j(handler, {"ok": True, "session": s.compact()}) # ── Session archive (POST) ── @@ -9387,7 +9422,11 @@ def handle_post(handler, parsed) -> bool: with _get_session_agent_lock(sid): s.archived = bool(body.get("archived", True)) s.save(touch_updated_at=False) - publish_session_list_changed("session_archive", profile=getattr(s, "profile", None)) + publish_session_list_changed( + "session_archive", + profile=getattr(s, "profile", None), + session_id=getattr(s, "session_id", sid), + ) return j(handler, {"ok": True, "session": s.compact(), **_worktree_retained_payload(s)}) # ── Session move to project (POST) ── @@ -9441,7 +9480,11 @@ def handle_post(handler, parsed) -> bool: s.save() finally: _move_lock.release() - publish_session_list_changed("session_move", profile=getattr(s, "profile", None)) + publish_session_list_changed( + "session_move", + profile=getattr(s, "profile", None), + session_id=getattr(s, "session_id", body["session_id"]), + ) return j(handler, {"ok": True, "session": s.compact()}) # ── Project CRUD (POST) ── @@ -13263,7 +13306,11 @@ def _start_chat_stream_for_session( "_status": 409, } if was_hidden_empty_session: - publish_session_list_changed("session_new", profile=getattr(s, "profile", None)) + publish_session_list_changed( + "session_new", + profile=getattr(s, "profile", None), + session_id=getattr(s, "session_id", None), + ) diag.stage("turn_journal_submitted") if diag else None journal_event = {} try: diff --git a/api/session_events.py b/api/session_events.py index 833d6243..e4a3efc6 100644 --- a/api/session_events.py +++ b/api/session_events.py @@ -31,6 +31,7 @@ def _sessions_changed_payload( reason: str, version: int, profile: str | None = None, + session_id: str | None = None, ) -> dict: payload = { "type": "sessions_changed", @@ -42,6 +43,9 @@ def _sessions_changed_payload( # renamed-root alias, and an unscoped refresh preserves the old fail-safe. if normalized_profile and not _profile_is_root_alias(normalized_profile): payload["profile"] = normalized_profile + normalized_session_id = str(session_id or "").strip() + if normalized_session_id: + payload["session_id"] = normalized_session_id return payload @@ -51,6 +55,12 @@ def _payload_profile(payload: dict | None) -> str | None: return value or None +def _payload_session_id(payload: dict | None) -> str | None: + value = payload.get("session_id") if isinstance(payload, dict) else None + value = str(value or "").strip() + return value or None + + def _coalesced_sessions_changed_payload(pending: dict | None, incoming: dict) -> dict: """Merge bounded-queue refresh events without dropping profile-relevant work. @@ -64,15 +74,23 @@ def _coalesced_sessions_changed_payload(pending: dict | None, incoming: dict) -> pending_profile = _payload_profile(pending) incoming_profile = _payload_profile(incoming) if pending_profile == incoming_profile: - return incoming + pending_session_id = _payload_session_id(pending) + incoming_session_id = _payload_session_id(incoming) + if pending_session_id == incoming_session_id: + return incoming + merged = dict(incoming) + merged.pop("session_id", None) + return merged merged = dict(incoming) merged.pop("profile", None) + merged.pop("session_id", None) return merged def publish_session_list_changed( reason: str = "session_changed", profile: str | None = None, + session_id: str | None = None, ) -> None: """Notify connected browsers that the session sidebar may be stale.""" global _SESSION_EVENTS_VERSION @@ -82,6 +100,7 @@ def publish_session_list_changed( reason=reason, version=_SESSION_EVENTS_VERSION, profile=profile, + session_id=session_id, ) subscribers = list(_SESSION_EVENTS_SUBSCRIBERS) listeners = list(_SESSION_LIST_CHANGED_LISTENERS) diff --git a/static/sessions.js b/static/sessions.js index 9a2e1da0..f589e80e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3741,6 +3741,12 @@ function _scheduleSessionEventsRefresh(reason){ }, 300); } +function _sessionEventTargetsActiveSession(payload){ + const eventSessionId = payload && typeof payload.session_id === 'string' ? payload.session_id : ''; + if(!eventSessionId) return false; + return !!(S.session && S.session.session_id && S.session.session_id === eventSessionId); +} + // ── #4151: focus-aware close for the two GLOBAL sidebar SSE streams ────────── // Each WebUI window holds up to three persistent SSE connections (session-events // + gateway + the per-session stream). #3992/#3996 close them on the Page @@ -3838,17 +3844,19 @@ function ensureSessionEventsSSE(){ }; _sessionEventsSSE.addEventListener('sessions_changed', (ev) => { const activeProfile = S.activeProfile || 'default'; + let eventTargetsActiveSession = false; try { const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data) : {}; const eventProfile = payload && typeof payload.profile === 'string' ? payload.profile : ''; if (!_sessionEventProfilesMatch(eventProfile, activeProfile)) { return; } + eventTargetsActiveSession = _sessionEventTargetsActiveSession(payload); } catch (_err) { // Non-JSON payload (or transient malformed event). Keep legacy behavior: // refresh once event was seen. } - _scheduleSessionEventsRefresh('event'); + _scheduleSessionEventsRefresh(eventTargetsActiveSession?'event-active-session':'event'); }); _sessionEventsSSE.onerror = () => { _sessionEventsNeedsRefreshOnOpen = true; diff --git a/tests/test_issue3225_rename_sync.py b/tests/test_issue3225_rename_sync.py index 9cbce069..e1a47f31 100644 --- a/tests/test_issue3225_rename_sync.py +++ b/tests/test_issue3225_rename_sync.py @@ -21,11 +21,11 @@ def test_rename_endpoint_syncs_title_to_state_db(): "rename handler must call _sync_session_title_to_insights(s) so the " "new title reaches state.db, matching the regenerate handler" ) - assert 'publish_session_list_changed("session_rename", profile=getattr(s, "profile", None))' in block + assert 'publish_session_list_changed(\n "session_rename",' in block # Sync must run BEFORE the list-changed publish, matching the regenerate # handler, so SSE subscribers refresh after state.db holds the new title. sync_idx = block.index("_sync_session_title_to_insights(s)") - publish_idx = block.index('publish_session_list_changed("session_rename", profile=getattr(s, "profile", None))') + publish_idx = block.index('publish_session_list_changed(\n "session_rename",') assert sync_idx < publish_idx, ( "rename must sync to state.db before publishing the list-changed event" ) diff --git a/tests/test_session_events.py b/tests/test_session_events.py index 474f51a2..954de3cf 100644 --- a/tests/test_session_events.py +++ b/tests/test_session_events.py @@ -36,23 +36,23 @@ def test_session_events_publish_for_minimal_sidebar_mutations(): elif reason == "session_import": assert f'publish_session_list_changed("{reason}")' in ROUTES, reason else: - assert f'publish_session_list_changed("{reason}",' in ROUTES, reason + assert f'"{reason}",' in ROUTES, reason - assert 'if worktree_info:\n publish_session_list_changed("session_new", profile=getattr(s, "profile", None))' in ROUTES + assert 'if worktree_info:\n publish_session_list_changed(\n "session_new",' in ROUTES assert "was_hidden_empty_session = _is_hidden_empty_session(s)" in ROUTES - assert 'if was_hidden_empty_session:\n publish_session_list_changed("session_new", profile=getattr(s, "profile", None))' in ROUTES - assert 'publish_session_list_changed("session_duplicate", profile=getattr(copied_session, "profile", None))' in ROUTES - assert 'publish_session_list_changed("session_rename", profile=getattr(s, "profile", None))' in ROUTES + assert 'if was_hidden_empty_session:\n publish_session_list_changed(\n "session_new",' in ROUTES + assert 'publish_session_list_changed(\n "session_duplicate",' in ROUTES + assert 'publish_session_list_changed(\n "session_rename",' in ROUTES assert '_persist_generated_session_title(s, next_title, event_reason="session_title_regenerate")' in ROUTES - assert '_publish_session_list_changed(event_reason, profile=getattr(session, "profile", None))' in ROUTES + assert "session_id=sid" in ROUTES assert 'event_profile = getattr(get_session(sid, metadata_only=True), "profile", None)' in ROUTES assert "Failed to resolve profile for deleted session" in ROUTES assert '_publish_session_list_changed("session_delete", profile=event_profile)' in ROUTES - assert 'publish_session_list_changed("session_branch", profile=getattr(branch, "profile", None))' in ROUTES - assert 'publish_session_list_changed("session_pin", profile=getattr(s, "profile", None))' in ROUTES - assert 'publish_session_list_changed("session_archive", profile=getattr(s, "profile", None))' in ROUTES - assert 'publish_session_list_changed("session_move", profile=getattr(s, "profile", None))' in ROUTES - assert 'profile=getattr(s, "profile", None)' in ROUTES + assert 'publish_session_list_changed(\n "session_branch",' in ROUTES + assert 'publish_session_list_changed(\n "session_pin",' in ROUTES + assert 'publish_session_list_changed(\n "session_archive",' in ROUTES + assert 'publish_session_list_changed(\n "session_move",' in ROUTES + assert 'session_id=getattr(' in ROUTES assert 'publish_session_list_changed("chat_start")' not in ROUTES assert '_publish_session_list_changed("cron_complete",' in ROUTES assert 'publish_session_list_changed("cron_complete",' in PROFILES @@ -74,17 +74,70 @@ def test_session_event_queue_same_profile_is_bounded_and_latest_wins(): session_events.unsubscribe_session_events(q) +def test_session_events_payload_tracks_session_id_when_available(): + from api import session_events + + q = session_events.subscribe_session_events() + try: + session_events.publish_session_list_changed( + "session_rename", + profile="profile-a", + session_id="session-123", + ) + payload = q.get_nowait() + assert payload["type"] == "sessions_changed" + assert payload["reason"] == "session_rename" + assert payload["profile"] == "profile-a" + assert payload["session_id"] == "session-123" + finally: + session_events.unsubscribe_session_events(q) + + +def test_session_event_queue_same_profile_different_sessions_coalesces_to_profile_refresh(): + from api import session_events + + q = session_events.subscribe_session_events() + try: + session_events.publish_session_list_changed( + "session_rename", + profile="profile-a", + session_id="session-a", + ) + session_events.publish_session_list_changed( + "session_pin", + profile="profile-a", + session_id="session-b", + ) + payload = q.get_nowait() + assert payload["type"] == "sessions_changed" + assert payload["reason"] == "session_pin" + assert payload["profile"] == "profile-a" + assert "session_id" not in payload + assert q.empty() + finally: + session_events.unsubscribe_session_events(q) + + def test_session_event_queue_profile_mismatch_coalesces_to_unscoped_refresh_all(): from api import session_events q = session_events.subscribe_session_events() try: - session_events.publish_session_list_changed("profile_a", profile="profile-a") - session_events.publish_session_list_changed("profile_b", profile="profile-b") + session_events.publish_session_list_changed( + "profile_a", + profile="profile-a", + session_id="session-a", + ) + session_events.publish_session_list_changed( + "profile_b", + profile="profile-b", + session_id="session-b", + ) payload = q.get_nowait() assert payload["type"] == "sessions_changed" assert payload["reason"] == "profile_b" assert "profile" not in payload + assert "session_id" not in payload assert q.empty() finally: session_events.unsubscribe_session_events(q) diff --git a/tests/test_session_title_regenerate_controls.py b/tests/test_session_title_regenerate_controls.py index 0bfa9a1d..6aaf97f6 100644 --- a/tests/test_session_title_regenerate_controls.py +++ b/tests/test_session_title_regenerate_controls.py @@ -65,7 +65,8 @@ def test_regenerate_helper_persists_generated_title_and_publishes_sidebar_refres assert "mark_session_title_generated(session)" in helper_block assert "session.save(touch_updated_at=False)" in helper_block assert "_sync_session_title_to_insights(session)" in helper_block - assert '_publish_session_list_changed(event_reason, profile=getattr(session, "profile", None))' in helper_block + assert "_publish_session_list_changed(" in helper_block + assert "session_id=sid" in helper_block def test_regenerate_endpoint_syncs_title_to_state_db_when_enabled(): diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index cc59d30c..d9d27a58 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -57,6 +57,9 @@ def test_session_list_external_refresh_uses_sse_invalidation_not_polling(): assert "const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data) : {};" in ensure_fn assert "const eventProfile = payload && typeof payload.profile === 'string' ? payload.profile : '';" in ensure_fn assert "if (!_sessionEventProfilesMatch(eventProfile, activeProfile)) {" in ensure_fn + assert "function _sessionEventTargetsActiveSession(payload)" in SESSIONS_JS + assert "typeof payload.session_id === 'string'" in SESSIONS_JS + assert "eventTargetsActiveSession?'event-active-session':'event'" in ensure_fn def test_session_event_profile_filter_tolerates_default_root_aliases():