feat(sessions): carry changed session_id in session-list-changed events (#4221) (#4233)

* fix(sessions): include target session in session events

* chore(release): stamp v0.51.430 (Release OQ) for #4221

---------

Co-authored-by: Dennis Soong <dso2ng@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
authored and GitHub committed 2026-06-15 00:10:01 -07:00
1 parent 8dc12713ca
commit d29da34c42
8 files changed
+169 -32

No files matched your search

+6
View File
@@ -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
+61 -14
View File
@@ -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:
+20 -1
View File
@@ -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)
+9 -1
View File
@@ -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;
+2 -2
View File
@@ -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"
)
+66 -13
View File
@@ -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)
@@ -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():
@@ -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():