fix: prevent state.db messages being silently dropped during sidecar merge

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 <noreply@anthropic.com>
This commit is contained in:
carryzuo00
2026-05-23 08:37:07 +00:00
committed by nesquena-hermes
parent 465b97a9f5
commit ee672df463
2 changed files with 20 additions and 11 deletions
+17 -9
View File
@@ -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:
+3 -2
View File
@@ -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 []