diff --git a/cli.py b/cli.py index 9fda8fa63..90b8b4ffe 100644 --- a/cli.py +++ b/cli.py @@ -3254,6 +3254,23 @@ class HermesCLI: _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}") 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) if restored: restored = [m for m in restored if m.get("role") != "session_meta"] @@ -3472,6 +3489,22 @@ class HermesCLI: ) 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) if restored: 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.") 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: _cprint(" Already on that session.") return diff --git a/hermes_state.py b/hermes_state.py index 0ea9815b5..ed95d25f4 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1039,6 +1039,71 @@ class SessionDB: result.append(msg) 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]]: """ Load messages in the OpenAI conversation format (role + content dicts). diff --git a/tests/hermes_state/test_resolve_resume_session_id.py b/tests/hermes_state/test_resolve_resume_session_id.py new file mode 100644 index 000000000..ec637c6d2 --- /dev/null +++ b/tests/hermes_state/test_resolve_resume_session_id.py @@ -0,0 +1,96 @@ +"""Regression guard for #15000: --resume 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 `` +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"