diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dad9ebe..4a62abaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ - Image lightbox now supports prev/next navigation when multiple images are present in the same message. Click `‹` / `›` buttons or use `←` / `→` keyboard arrows to browse; an image counter (`1 / 5`) is shown at the bottom. (#2967) +### Fixed + +- Session switching now keeps the initial metadata fetch on `/api/session?messages=0&resolve_model=0` and defers stale model/provider repair until after the new session is assigned, so first paint does not block on cold model catalog hydration. +- Metadata-only session loads now use a cheap state summary instead of full transcript reconciliation, while still detecting real external state.db growth and ignoring restamped replay rows that would otherwise retrigger refresh polling. +- Session transcript reconciliation now precomputes visible-duplicate lookup state instead of recomputing loose-content normalization for every state.db row, reducing long-session tail-load latency without changing the append-only merge contract. + ## [v0.51.138] — 2026-05-25 — Release DJ (stage-batch20 — 7-PR ultra-safe batch) ### Added diff --git a/api/models.py b/api/models.py index d17b7963..e060f94e 100644 --- a/api/models.py +++ b/api/models.py @@ -614,9 +614,10 @@ class Session: 'enabled_toolsets', 'composer_draft', ] meta = {k: getattr(self, k, None) for k in METADATA_FIELDS} + meta['message_count'] = len(self.messages or []) meta['messages'] = self.messages meta['tool_calls'] = self.tool_calls - # Fields not in METADATA_FIELDS (e.g. last_usage, message_count) go at the end + # Fields not in METADATA_FIELDS (e.g. last_usage) go at the end extra = {k: v for k, v in self.__dict__.items() if k not in METADATA_FIELDS and k not in ('messages', 'tool_calls') and not k.startswith('_')} @@ -3225,6 +3226,53 @@ def get_state_db_session_messages(sid, *, stitch_continuations: bool = False, pr return msgs +def get_state_db_session_summary(sid, *, profile=None) -> dict: + """Return a cheap message count/timestamp summary for one state.db session.""" + try: + import sqlite3 + except ImportError: + return {"message_count": 0, "last_message_at": 0.0} + + if isinstance(profile, str) and profile: + db_path = _get_profile_home(profile) / 'state.db' + if not db_path.exists(): + db_path = _active_state_db_path() + else: + db_path = _active_state_db_path() + if not sid or not db_path.exists(): + return {"message_count": 0, "last_message_at": 0.0} + + try: + with closing(sqlite3.connect(str(db_path))) as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute("PRAGMA table_info(messages)") + available = {str(row['name']) for row in cur.fetchall()} + if 'session_id' not in available: + return {"message_count": 0, "last_message_at": 0.0} + if 'timestamp' in available: + cur.execute( + "SELECT COUNT(*) AS message_count, MAX(timestamp) AS last_message_at " + "FROM messages WHERE session_id = ?", + (str(sid),), + ) + row = cur.fetchone() + if not row: + return {"message_count": 0, "last_message_at": 0.0} + return { + "message_count": max(0, int(row["message_count"] or 0)), + "last_message_at": float(row["last_message_at"] or 0) if row["last_message_at"] is not None else 0.0, + } + cur.execute("SELECT COUNT(*) AS message_count FROM messages WHERE session_id = ?", (str(sid),)) + row = cur.fetchone() + return { + "message_count": max(0, int(row["message_count"] or 0)) if row else 0, + "last_message_at": 0.0, + } + except Exception: + return {"message_count": 0, "last_message_at": 0.0} + + def _normalized_message_timestamp_for_key(value): if value is None or value == "": return "" @@ -3296,19 +3344,38 @@ def _session_message_visible_key(msg: dict): ) -def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple]): +def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict: + by_role = {} + loose_by_key = {} + for key in visible_keys: + try: + role, content = key + except (TypeError, ValueError): + continue + if not content: + continue + by_role.setdefault(role, []).append(key) + loose_by_key[key] = _loose_session_message_content(content) + return {"keys": visible_keys, "by_role": by_role, "loose_by_key": loose_by_key} + + +def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lookup: dict | None = None): if visible_key in visible_keys: return visible_key role, content = visible_key if not content: return None - for existing_role, existing_content in visible_keys: + if lookup is None: + lookup = _build_visible_duplicate_lookup(visible_keys) + loose_content = None + for existing_role, existing_content in lookup.get("by_role", {}).get(role, []): if role != existing_role or not existing_content: continue if content in existing_content or existing_content in content: return (existing_role, existing_content) - loose_content = _loose_session_message_content(content) - loose_existing = _loose_session_message_content(existing_content) + if loose_content is None: + loose_content = _loose_session_message_content(content) + loose_existing = lookup.get("loose_by_key", {}).get((existing_role, existing_content), "") if loose_content and loose_existing and ( loose_content in loose_existing or loose_existing in loose_content ): @@ -3414,6 +3481,7 @@ def merge_session_messages_append_only( sidecar_visible_counts[visible_key] = sidecar_visible_counts.get(visible_key, 0) + 1 sidecar_visible_sequence.append(visible_key) merged_messages.append(msg) + sidecar_visible_lookup = _build_visible_duplicate_lookup(sidecar_visible_keys) state_replay_idx = 0 skipped_state_visible_counts = {} for msg in state_messages: @@ -3429,7 +3497,11 @@ def merge_session_messages_append_only( replays_sidecar_prefix = True state_replay_idx += 1 if replays_sidecar_prefix: - matched_visible_key = _matching_visible_duplicate(visible_key, sidecar_visible_keys) + matched_visible_key = _matching_visible_duplicate( + visible_key, + sidecar_visible_keys, + sidecar_visible_lookup, + ) if matched_visible_key is not None: skipped_state_visible_counts[matched_visible_key] = ( skipped_state_visible_counts.get(matched_visible_key, 0) + 1 @@ -3449,7 +3521,11 @@ def merge_session_messages_append_only( continue if key in seen_message_keys: continue - matched_visible_key = _matching_visible_duplicate(visible_key, sidecar_visible_keys) + matched_visible_key = _matching_visible_duplicate( + visible_key, + sidecar_visible_keys, + sidecar_visible_lookup, + ) if matched_visible_key is not None: skipped_count = skipped_state_visible_counts.get(matched_visible_key, 0) sidecar_count = sidecar_visible_counts.get(matched_visible_key, 0) diff --git a/api/routes.py b/api/routes.py index 61d9de82..ec246d17 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2206,25 +2206,55 @@ def _message_summary(messages) -> dict: def _metadata_only_message_summary(sid: str, profile: str | None = None) -> dict: - """Return the reconciled message summary used by metadata-only session loads. + """Return the cheap message summary used by metadata-only session loads. - Threads ``profile=`` through to ``get_state_db_session_messages`` so + Threads ``profile=`` through to ``get_state_db_session_summary`` so background-thread reads land on the correct profile's state.db (per the cookie-bound profile selector — fixes the same TLS-vs-thread race the #2762 fix addressed for write paths). + + This intentionally does not full-read or merge transcripts. If state.db has + grown beyond the sidecar count, report that growth so active-session polling + can refresh. If state.db only contains restamped replay rows at or below the + sidecar count, keep the sidecar metadata so polling does not loop forever on + a false "newer transcript" signal. """ - sidecar_session = Session.load(sid) - sidecar_messages = [] + sidecar_session = Session.load_metadata_only(sid) + sidecar_count = 0 + sidecar_last_message_at = 0.0 if sidecar_session: - sidecar_messages = getattr(sidecar_session, "messages", []) or [] - state_db_messages = get_state_db_session_messages(sid, profile=profile) - return _message_summary( - merge_session_messages_append_only( - sidecar_messages, - state_db_messages, - truncation_watermark=getattr(sidecar_session, "truncation_watermark", None), - ) - ) + sidecar_count = _numeric_count(getattr(sidecar_session, "_metadata_message_count", None)) + if sidecar_count <= 0: + sidecar_count = _numeric_count(sidecar_session.compact().get("message_count")) + try: + sidecar_last_message_at = float(getattr(sidecar_session, "updated_at", 0) or 0) + except (TypeError, ValueError): + sidecar_last_message_at = 0.0 + if getattr(sidecar_session, "truncation_watermark", None) is not None: + # Intentional: once the user has truncated this sidecar, metadata + # polling must keep the sidecar as authoritative. A full message + # load can still apply the watermark-aware merge, but the cheap + # metadata path should not treat later state.db rows as external + # growth and resurrect turns the user deliberately cut away. + return { + "message_count": sidecar_count, + "last_message_at": sidecar_last_message_at, + } + state_summary = get_state_db_session_summary(sid, profile=profile) + state_count = _numeric_count(state_summary.get("message_count")) + try: + state_last_message_at = float(state_summary.get("last_message_at") or 0) + except (TypeError, ValueError): + state_last_message_at = 0.0 + if state_count > sidecar_count and state_last_message_at > sidecar_last_message_at: + return { + "message_count": state_count, + "last_message_at": state_last_message_at, + } + return { + "message_count": sidecar_count, + "last_message_at": sidecar_last_message_at, + } def _session_requires_cli_metadata_lookup(session) -> bool: @@ -2457,6 +2487,7 @@ from api.models import ( get_cli_sessions, get_cli_session_messages, get_state_db_session_messages, + get_state_db_session_summary, merge_session_messages_append_only, _session_message_merge_key, prune_session_from_index, diff --git a/static/sessions.js b/static/sessions.js index ac689194..3d7a82b6 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -579,15 +579,13 @@ async function loadSession(sid){ const _msgInner = $('msgInner'); if (_msgInner && currentSid !== sid) _msgInner.innerHTML = '