diff --git a/api/models.py b/api/models.py index 2ad051b2..bc1efcc6 100644 --- a/api/models.py +++ b/api/models.py @@ -1721,7 +1721,12 @@ class Session: n += 1 return n - def compact(self, include_runtime=False, active_stream_ids=None) -> dict: + def compact( + self, + include_runtime=False, + active_stream_ids=None, + sidebar_metadata_only=False, + ) -> dict: active_stream_ids = active_stream_ids if active_stream_ids is not None else set() has_pending_user_message = bool(self.pending_user_message) message_count = ( @@ -1734,7 +1739,7 @@ class Session: last_message_at = _last_message_timestamp(self.messages) or self.updated_at if has_pending_user_message and self.pending_started_at: last_message_at = self.pending_started_at - return { + compact = { 'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, @@ -1809,6 +1814,15 @@ class Session: self.active_stream_id, active_stream_ids ) if include_runtime else False, } + if sidebar_metadata_only: + for key in ( + 'compression_anchor_summary', 'compression_anchor_details', + 'context_engine_state', 'compression_recovery', + 'gateway_routing_history', 'composer_draft', + 'process_wakeup_pause', 'share_token', + ): + compact.pop(key, None) + return compact PROCESS_WAKEUP_PROVIDER_UNAVAILABLE_TYPES = frozenset({ @@ -6278,7 +6292,12 @@ def _diag_stage(diag, name: str) -> None: pass -def all_sessions(diag=None, *, include_lineage_metadata: bool = True): +def all_sessions( + diag=None, + *, + include_lineage_metadata: bool = True, + sidebar_metadata_only: bool = False, +): _diag_stage(diag, "all_sessions.active_streams") active_stream_ids = _active_stream_ids() # Phase C: try index first for O(1) read; fall back to full scan @@ -6318,7 +6337,7 @@ def all_sessions(diag=None, *, include_lineage_metadata: bool = True): _diag_stage(diag, "all_sessions.backfill_load") full = Session.load(s.get('session_id')) if full: - index[i] = full.compact() + index[i] = full.compact(sidebar_metadata_only=sidebar_metadata_only) backfilled.append(full) if backfilled: try: @@ -6340,6 +6359,7 @@ def all_sessions(diag=None, *, include_lineage_metadata: bool = True): index_map[s.session_id] = s.compact( include_runtime=True, active_stream_ids=active_stream_ids, + sidebar_metadata_only=sidebar_metadata_only, ) missing_persisted_ids = [] if persisted_ids is not None: @@ -6372,6 +6392,7 @@ def all_sessions(diag=None, *, include_lineage_metadata: bool = True): index_map[sidecar.session_id] = sidecar.compact( include_runtime=True, active_stream_ids=active_stream_ids, + sidebar_metadata_only=sidebar_metadata_only, ) recovered_sidecars.append(sidecar) if recovered_sidecars: @@ -6459,7 +6480,11 @@ def all_sessions(diag=None, *, include_lineage_metadata: bool = True): # Hide empty Untitled sessions from the UI entirely — kept consistent with the # index-path filter above. No grace window: a 0-message Untitled session is # never shown regardless of age (#1171). Same streaming exemption as above (#1327). - result = [s.compact(include_runtime=True, active_stream_ids=active_stream_ids) for s in out if not ( + result = [s.compact( + include_runtime=True, + active_stream_ids=active_stream_ids, + sidebar_metadata_only=sidebar_metadata_only, + ) for s in out if not ( s.title == 'Untitled' and len(s.messages) == 0 and not s.active_stream_id diff --git a/api/route_session_list_cache.py b/api/route_session_list_cache.py index a231217c..b0bae89a 100644 --- a/api/route_session_list_cache.py +++ b/api/route_session_list_cache.py @@ -1,7 +1,6 @@ """Session-list cache helpers extracted from api.routes.""" import os -import copy import re import threading import time @@ -44,6 +43,80 @@ _SESSIONS_CACHE_ALL_PROFILES_INVALIDATION_VERSION = 0 _SESSIONS_CACHE_PROFILE_INVALIDATION_VERSION: dict[str, int] = {} +def _session_list_cache_sidebar_fields() -> set[str]: + """Return the canonical bounded field set used by the list response.""" + try: + import api.routes as _routes + + fields = getattr(_routes, "_SIDEBAR_SESSION_RESPONSE_FIELDS", None) + if isinstance(fields, set): + return fields + except Exception: + pass + # The fallback is intentionally small and contains no transcript-bearing + # fields. Normal runtime calls resolve the canonical set above after + # api.routes has finished importing. + return { + "session_id", "title", "workspace", "model", "model_provider", + "message_count", "user_message_count", "created_at", "updated_at", + "last_message_at", "pinned", "archived", "project_id", "profile", + "source_tag", "session_source", "is_streaming", "active_stream_id", + "has_pending_user_message", "pending_started_at", "default_hidden", + "worktree_path", "worktree_branch", "parent_session_id", + "parent_title", "parent_source", "relationship_type", + "pre_compression_snapshot", "read_only", "is_read_only", + "gateway_routing", + } + + +def _session_list_cache_bounded_payload(payload: dict) -> dict: + """Project a builder payload to data the sidebar can actually consume. + + Builders operate on full session snapshots because they also perform + reconciliation and lineage decisions. The cache must not retain those + snapshots: a single long transcript can otherwise make every cache set/get + allocate hundreds of megabytes via deepcopy(). + """ + fields = _session_list_cache_sidebar_fields() + + def project_rows(rows): + projected = [] + for row in rows or []: + if not isinstance(row, dict): + projected.append({}) + continue + item = {key: row[key] for key in fields if key in row} + if isinstance(item.get("gateway_routing"), dict): + item["gateway_routing"] = dict(item["gateway_routing"]) + projected.append(item) + return projected + + bounded = { + key: value + for key, value in payload.items() + if key not in {"sessions", "sidebar_reference_sessions"} + } + bounded["sessions"] = project_rows(payload.get("sessions")) + bounded["sidebar_reference_sessions"] = project_rows( + payload.get("sidebar_reference_sessions") + ) + return bounded + + +def _session_list_cache_copy_payload(payload: dict) -> dict: + """Copy only the already-bounded cache shape for request-local mutation.""" + copied = dict(payload) + copied["sessions"] = [dict(row) for row in payload.get("sessions", [])] + copied["sidebar_reference_sessions"] = [ + dict(row) for row in payload.get("sidebar_reference_sessions", []) + ] + for rows in (copied["sessions"], copied["sidebar_reference_sessions"]): + for row in rows: + if isinstance(row.get("gateway_routing"), dict): + row["gateway_routing"] = dict(row["gateway_routing"]) + return copied + + def get_session_list_cache_snapshot() -> dict[str, object]: """Return scalar cache occupancy without waiting or changing LRU state. @@ -240,7 +313,7 @@ def _session_list_cache_get( if stamp != current_stamp: if allow_stale: _SESSIONS_CACHE.move_to_end(key) - return copy.deepcopy(payload), False + return _session_list_cache_copy_payload(payload), False _SESSIONS_CACHE.pop(key, None) return None, False # #4808: widen the freshness window while a turn is streaming so the fixed @@ -251,10 +324,10 @@ def _session_list_cache_get( fresh = (now - ts) < ttl if fresh: _SESSIONS_CACHE.move_to_end(key) - return copy.deepcopy(payload), True + return _session_list_cache_copy_payload(payload), True if allow_stale: _SESSIONS_CACHE.move_to_end(key) - return copy.deepcopy(payload), False + return _session_list_cache_copy_payload(payload), False _SESSIONS_CACHE.pop(key, None) return None, False @@ -282,8 +355,9 @@ def _session_list_cache_set(key: tuple, payload: dict) -> None: if not isinstance(payload, dict): return stamp = _session_list_cache_resolved_source_stamp(key) + bounded = _session_list_cache_bounded_payload(payload) with _SESSIONS_CACHE_LOCK: - _SESSIONS_CACHE[key] = (time.monotonic(), stamp, copy.deepcopy(payload)) + _SESSIONS_CACHE[key] = (time.monotonic(), stamp, bounded) _SESSIONS_CACHE.move_to_end(key) while len(_SESSIONS_CACHE) > _SESSIONS_CACHE_MAX_ENTRIES: _SESSIONS_CACHE.popitem(last=False) diff --git a/api/routes.py b/api/routes.py index 189fc524..c4b6bbef 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2295,8 +2295,11 @@ def _build_session_list_cache_payload( ) def _all_sessions_for_sidebar(): + kwargs = {"diag": diag, "include_lineage_metadata": False} + if _callable_accepts_kwarg(all_sessions, "sidebar_metadata_only"): + kwargs["sidebar_metadata_only"] = True if _callable_accepts_kwarg(all_sessions, "include_lineage_metadata"): - return all_sessions(diag=diag, include_lineage_metadata=False) + return all_sessions(**kwargs) # Focused tests and third-party callers sometimes monkeypatch # routes.all_sessions with the historical diag-only signature. return all_sessions(diag=diag) diff --git a/tests/test_session_list_cache_bounded.py b/tests/test_session_list_cache_bounded.py new file mode 100644 index 00000000..9572430e --- /dev/null +++ b/tests/test_session_list_cache_bounded.py @@ -0,0 +1,88 @@ +"""Regression tests for bounded sidebar session-list caching.""" + +from api import route_session_list_cache as cache +from api.models import Session + + +def _key(): + return cache._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=False, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + ) + + +def test_cache_drops_full_transcript_fields_before_storing(monkeypatch): + cache._session_list_cache_clear() + payload = { + "sessions": [{ + "session_id": "huge", + "title": "Large session", + "message_count": 1, + "messages": [{"role": "assistant", "content": "x" * 1000000}], + "tool_calls": [{"arguments": "y" * 1000000}], + "context_messages": ["z" * 1000000], + "gateway_routing_history": [{"provider": "old"}] * 1000, + }], + "sidebar_reference_sessions": [], + "settings": {"show_cli_sessions": False}, + } + monkeypatch.setattr(cache, "_session_list_cache_resolved_source_stamp", lambda _key: ("stable",)) + + cache._session_list_cache_set(_key(), payload) + cached, fresh = cache._session_list_cache_get(_key(), allow_stale=False) + + assert fresh is True + assert cached["sessions"] == [{ + "session_id": "huge", + "title": "Large session", + "message_count": 1, + }] + assert "messages" not in cached["sessions"][0] + assert "tool_calls" not in cached["sessions"][0] + assert "context_messages" not in cached["sessions"][0] + assert "gateway_routing_history" not in cached["sessions"][0] + + +def test_cache_read_returns_independent_bounded_rows(monkeypatch): + cache._session_list_cache_clear() + monkeypatch.setattr(cache, "_session_list_cache_resolved_source_stamp", lambda _key: ("stable",)) + key = _key() + cache._session_list_cache_set(key, {"sessions": [{"session_id": "s1", "title": "One"}]}) + + first, _ = cache._session_list_cache_get(key) + first["sessions"][0]["title"] = "mutated" + second, _ = cache._session_list_cache_get(key) + + assert second["sessions"][0]["title"] == "One" + + +def test_sidebar_compact_omits_heavy_session_metadata(): + session = Session( + session_id="heavy", + title="Heavy", + messages=[{"role": "user", "content": "hello"}], + compression_anchor_summary="s" * 1000000, + compression_anchor_details={"detail": "d" * 1000000}, + context_engine_state={"state": "x" * 1000000}, + compression_recovery={"recovery": "r" * 1000000}, + gateway_routing_history=[{"provider": "old"}] * 10000, + composer_draft={"draft": "c" * 1000000}, + process_wakeup_pause={"pause": "p" * 1000000}, + share_token="token-value", + ) + + normal = session.compact() + sidebar = session.compact(sidebar_metadata_only=True) + + assert normal["gateway_routing_history"] + assert normal["compression_anchor_summary"] + for field in ( + "compression_anchor_summary", "compression_anchor_details", + "context_engine_state", "compression_recovery", + "gateway_routing_history", "composer_draft", + "process_wakeup_pause", "share_token", + ): + assert field not in sidebar