fix(resume): redirect --resume to the descendant that actually holds the messages

When context compression fires mid-session, run_agent's _compress_context
ends the current session, creates a new child session linked by
parent_session_id, and resets the SQLite flush cursor. New messages land
in the child; the parent row ends up with message_count = 0. A user who
runs 'hermes --resume <original_id>' sees a blank chat even though the
transcript exists — just under a descendant id.

PR #12920 already fixed the exit banner to print the live descendant id
at session end, but that didn't help users who resume by a session id
captured BEFORE the banner update (scripts, sessions list, old terminal
scrollback) or who type the parent id manually.

Fix: add SessionDB.resolve_resume_session_id() which walks the
parent→child chain forward and returns the first descendant with at
least one message row. Wire it into all three resume entry points:

  - HermesCLI._preload_resumed_session() (early resume at run() time)
  - HermesCLI._init_agent() (the classical resume path)
  - /resume slash command

Semantics preserved when the chain has no descendants with messages,
when the requested session already has messages, or when the id is
unknown. A depth cap of 32 guards against malformed loops.

This does NOT concatenate the pre-compression parent transcript into
the child — the whole point of compression is to shrink that, so
replaying it would blow the cache budget we saved. We just jump to
the post-compression child. The summary already reflects what was
compressed away.

Tests: tests/hermes_state/test_resolve_resume_session_id.py covers
  - the exact 6-session shape from the issue
  - passthrough when session has messages / no descendants
  - passthrough for nonexistent / empty / None input
  - middle-of-chain redirects
  - fork resolution (prefers most-recent child)

Closes #15000
This commit is contained in:
Teknium 2026-04-24 03:01:24 -07:00 committed by Teknium
parent 166b960fe4
commit f24956ba12
3 changed files with 210 additions and 0 deletions

49
cli.py
View file

@ -3254,6 +3254,23 @@ class HermesCLI:
_cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}") _cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
_cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}") _cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}")
return False return False
# If the requested session is the (empty) head of a compression
# chain, walk to the descendant that actually holds the messages.
# See #15000 and SessionDB.resolve_resume_session_id.
try:
resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
except Exception:
resolved_id = self.session_id
if resolved_id and resolved_id != self.session_id:
ChatConsole().print(
f"[{_DIM}]Session {_escape(self.session_id)} was compressed into "
f"{_escape(resolved_id)}; resuming the descendant with your "
f"transcript.[/]"
)
self.session_id = resolved_id
resolved_meta = self._session_db.get_session(self.session_id)
if resolved_meta:
session_meta = resolved_meta
restored = self._session_db.get_messages_as_conversation(self.session_id) restored = self._session_db.get_messages_as_conversation(self.session_id)
if restored: if restored:
restored = [m for m in restored if m.get("role") != "session_meta"] restored = [m for m in restored if m.get("role") != "session_meta"]
@ -3472,6 +3489,22 @@ class HermesCLI:
) )
return False return False
# If the requested session is the (empty) head of a compression chain,
# walk to the descendant that actually holds the messages. See #15000.
try:
resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
except Exception:
resolved_id = self.session_id
if resolved_id and resolved_id != self.session_id:
self._console_print(
f"[dim]Session {self.session_id} was compressed into "
f"{resolved_id}; resuming the descendant with your transcript.[/]"
)
self.session_id = resolved_id
resolved_meta = self._session_db.get_session(self.session_id)
if resolved_meta:
session_meta = resolved_meta
restored = self._session_db.get_messages_as_conversation(self.session_id) restored = self._session_db.get_messages_as_conversation(self.session_id)
if restored: if restored:
restored = [m for m in restored if m.get("role") != "session_meta"] restored = [m for m in restored if m.get("role") != "session_meta"]
@ -4686,6 +4719,22 @@ class HermesCLI:
_cprint(" Use /history or `hermes sessions list` to see available sessions.") _cprint(" Use /history or `hermes sessions list` to see available sessions.")
return return
# If the target is the empty head of a compression chain, redirect to
# the descendant that actually holds the transcript. See #15000.
try:
resolved_id = self._session_db.resolve_resume_session_id(target_id)
except Exception:
resolved_id = target_id
if resolved_id and resolved_id != target_id:
_cprint(
f" Session {target_id} was compressed into {resolved_id}; "
f"resuming the descendant with your transcript."
)
target_id = resolved_id
resolved_meta = self._session_db.get_session(target_id)
if resolved_meta:
session_meta = resolved_meta
if target_id == self.session_id: if target_id == self.session_id:
_cprint(" Already on that session.") _cprint(" Already on that session.")
return return

View file

@ -1039,6 +1039,71 @@ class SessionDB:
result.append(msg) result.append(msg)
return result return result
def resolve_resume_session_id(self, session_id: str) -> str:
"""Redirect a resume target to the descendant session that holds the messages.
Context compression ends the current session and forks a new child session
(linked via ``parent_session_id``). The flush cursor is reset, so the
child is where new messages actually land the parent ends up with
``message_count = 0`` rows unless messages had already been flushed to
it before compression. See #15000.
This helper walks ``parent_session_id`` forward from ``session_id`` and
returns the first descendant in the chain that has at least one message
row. If the original session already has messages, or no descendant
has any, the original ``session_id`` is returned unchanged.
The chain is always walked via the child whose ``started_at`` is
latest; that matches the single-chain shape that compression creates.
A depth cap (32) guards against accidental loops in malformed data.
"""
if not session_id:
return session_id
with self._lock:
# If this session already has messages, nothing to redirect.
try:
row = self._conn.execute(
"SELECT 1 FROM messages WHERE session_id = ? LIMIT 1",
(session_id,),
).fetchone()
except Exception:
return session_id
if row is not None:
return session_id
# Walk descendants: at each step, pick the most-recently-started
# child session; stop once we find one with messages.
current = session_id
seen = {current}
for _ in range(32):
try:
child_row = self._conn.execute(
"SELECT id FROM sessions "
"WHERE parent_session_id = ? "
"ORDER BY started_at DESC, id DESC LIMIT 1",
(current,),
).fetchone()
except Exception:
return session_id
if child_row is None:
return session_id
child_id = child_row["id"] if hasattr(child_row, "keys") else child_row[0]
if not child_id or child_id in seen:
return session_id
seen.add(child_id)
try:
msg_row = self._conn.execute(
"SELECT 1 FROM messages WHERE session_id = ? LIMIT 1",
(child_id,),
).fetchone()
except Exception:
return session_id
if msg_row is not None:
return child_id
current = child_id
return session_id
def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]: def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
""" """
Load messages in the OpenAI conversation format (role + content dicts). Load messages in the OpenAI conversation format (role + content dicts).

View file

@ -0,0 +1,96 @@
"""Regression guard for #15000: --resume <id> after compression loses messages.
Context compression ends the current session and forks a new child session
(linked by ``parent_session_id``). The SQLite flush cursor is reset, so
only the latest descendant ends up with rows in the ``messages`` table
the parent row has ``message_count = 0``. ``hermes --resume <parent_id>``
used to load zero rows and show a blank chat.
``SessionDB.resolve_resume_session_id()`` walks the parent child chain
and redirects to the first descendant that actually has messages. These
tests pin that behaviour.
"""
import time
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path):
return SessionDB(tmp_path / "state.db")
def _make_chain(db: SessionDB, ids_with_parent):
"""Create sessions in order, forcing started_at so ordering is deterministic."""
base = int(time.time()) - 10_000
for i, (sid, parent) in enumerate(ids_with_parent):
db.create_session(sid, source="cli", parent_session_id=parent)
db._conn.execute(
"UPDATE sessions SET started_at = ? WHERE id = ?",
(base + i * 100, sid),
)
db._conn.commit()
def test_redirects_from_empty_head_to_descendant_with_messages(db):
# Reproducer shape from #15000: 6 sessions, only the 5th holds messages.
_make_chain(db, [
("head", None),
("mid1", "head"),
("mid2", "mid1"),
("mid3", "mid2"),
("bulk", "mid3"), # has messages
("tail", "bulk"), # empty tail after another compression
])
for i in range(5):
db.append_message("bulk", role="user", content=f"msg {i}")
assert db.resolve_resume_session_id("head") == "bulk"
def test_returns_self_when_session_has_messages(db):
_make_chain(db, [("root", None), ("child", "root")])
db.append_message("root", role="user", content="hi")
assert db.resolve_resume_session_id("root") == "root"
def test_returns_self_when_no_descendant_has_messages(db):
_make_chain(db, [("root", None), ("child1", "root"), ("child2", "child1")])
assert db.resolve_resume_session_id("root") == "root"
def test_returns_self_for_isolated_session(db):
db.create_session("isolated", source="cli")
assert db.resolve_resume_session_id("isolated") == "isolated"
def test_returns_self_for_nonexistent_session(db):
assert db.resolve_resume_session_id("does_not_exist") == "does_not_exist"
def test_empty_session_id_passthrough(db):
assert db.resolve_resume_session_id("") == ""
assert db.resolve_resume_session_id(None) is None
def test_walks_from_middle_of_chain(db):
# If the user happens to know an intermediate ID, we still find the msg-bearing descendant.
_make_chain(db, [("a", None), ("b", "a"), ("c", "b"), ("d", "c")])
db.append_message("d", role="user", content="x")
assert db.resolve_resume_session_id("b") == "d"
assert db.resolve_resume_session_id("c") == "d"
def test_prefers_most_recent_child_when_fork_exists(db):
# If a session was somehow forked (two children), pick the latest one.
# In practice, compression only produces single-chain shape, but the helper
# should degrade gracefully.
_make_chain(db, [
("parent", None),
("older_fork", "parent"),
("newer_fork", "parent"),
])
db.append_message("newer_fork", role="user", content="x")
assert db.resolve_resume_session_id("parent") == "newer_fork"