From ee672df463e285791e4466e6132297e5feb4a1df Mon Sep 17 00:00:00 2001 From: carryzuo00 Date: Sat, 23 May 2026 08:37:07 +0000 Subject: [PATCH] fix: prevent state.db messages being silently dropped during sidecar merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs combined to cause historical messages to vanish from the WebUI after a session was continued in a later conversation. **Bug 1 — missing `id` in state.db SELECT (models.py)** `get_state_db_session_messages()` did not include the `id` column in its SELECT, so every row got a `("legacy", ...)` merge key instead of `("message_id", ...)`. The timestamp gate in `merge_session_messages_append_only()` explicitly exempts `message_id`-keyed rows from its "skip if older than newest sidecar message" rule, but legacy-keyed rows are unconditionally dropped. With a session that has any new sidecar messages (max_sidecar_timestamp == today), all older state.db rows were silently discarded. Fix: include `id` when the column is present so rows get proper `("message_id", ...)` keys and survive the timestamp filter. **Bug 2 — always reads active profile's state.db, not the session's (models.py + routes.py)** `get_state_db_session_messages()` always called `_active_state_db_path()`, which returns the currently-active profile's database. Sessions belonging to a different profile (e.g. `jump`) were read from the wrong state.db, returning either no rows or unrelated ones. Fix: add an optional `profile` parameter; when supplied, resolve the path via `_get_profile_home(profile)` with a fallback to the active path if the profile-specific db does not exist. The call-site in `routes.py` now reads `session.profile` and passes it through. Co-Authored-By: Claude Sonnet 4.6 --- api/models.py | 26 +++++++++++++++++--------- api/routes.py | 5 +++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/api/models.py b/api/models.py index 652bde3f..1fde1b51 100644 --- a/api/models.py +++ b/api/models.py @@ -2815,21 +2815,28 @@ def _json_loads_if_string(value): return value -def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) -> list: - """Read messages for a Hermes session from the active profile's state.db. +def get_state_db_session_messages(sid, *, stitch_continuations: bool = False, profile=None) -> list: + """Read messages for a Hermes session from state.db. - This generic reader intentionally works for any session source, including - WebUI-origin sessions that were later updated through another Hermes surface - such as the Gateway API Server. When ``stitch_continuations`` is true it - preserves the historical CLI/external-agent behavior of walking compatible - compression/close parent segments before reading messages. + When *profile* is supplied, reads from that profile's state.db; otherwise + falls back to the active profile's state.db. This generic reader works for + any session source, including WebUI-origin sessions that were later updated + through another Hermes surface such as the Gateway API Server. When + ``stitch_continuations`` is true it preserves the historical CLI/external-agent + behavior of walking compatible compression/close parent segments before reading + messages. """ try: import sqlite3 except ImportError: return [] - db_path = _active_state_db_path() + 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 db_path.exists(): return [] @@ -2852,7 +2859,8 @@ def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) -> 'reasoning_content', 'codex_message_items', ] - selected = ['role', 'content', 'timestamp'] + [c for c in optional if c in available] + id_col = ['id'] if 'id' in available else [] + selected = id_col + ['role', 'content', 'timestamp'] + [c for c in optional if c in available] session_chain = [str(sid)] if stitch_continuations: diff --git a/api/routes.py b/api/routes.py index efc76b06..e6d0ffda 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3752,17 +3752,18 @@ def handle_get(handler, parsed) -> bool: cli_messages = [] state_db_messages = [] sidecar_metadata_messages = None + _session_profile = getattr(s, 'profile', None) or None if is_messaging_session: cli_messages = get_cli_session_messages(sid) elif load_messages: - state_db_messages = get_state_db_session_messages(sid) + state_db_messages = get_state_db_session_messages(sid, profile=_session_profile) elif not is_messaging_session: # Metadata-only callers still need the same append-only # reconciliation contract as full loads. A raw state.db summary # can count stale rows that the merge intentionally filters out, # which makes sidebar polling think the transcript is always # newer than the loaded conversation. - state_db_messages = get_state_db_session_messages(sid) + state_db_messages = get_state_db_session_messages(sid, profile=_session_profile) sidecar_metadata_session = Session.load(sid) sidecar_metadata_messages = ( getattr(sidecar_metadata_session, "messages", []) or []