* 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:
8 files changed
+168
-31
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
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
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user