diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index 866876d70f..2506ac1080 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -657,11 +657,11 @@ def _guard_exports(db, session_ids: list[str]) -> None: return try: for session_id in session_ids: - db.assert_export_safe(session_id, max_messages=limit) + db.assert_export_safe(session_id, max_messages=limit, include_inactive=True) except SessionExportTooLargeError as exc: raise ConsoleCommandError( - f"Session '{exc.session_id}' has more than {limit:,} active " - "messages; in-memory export is capped per session. " + f"Session '{exc.session_id}' has more than {limit:,} " + "exportable messages; in-memory export is capped per session. " "Use the Sessions page's streaming Export action, or set " "sessions.max_export_messages: 0 in config.yaml to disable " "the guard.") from exc @@ -676,13 +676,15 @@ def _sessions_export(_engine: HermesConsoleEngine, args: list[str]) -> None: if not resolved_session_id: raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") _guard_exports(db, [resolved_session_id]) - rows = [db.export_session(resolved_session_id)] + # Transfer projection: every row with its active/compacted flags, so an import of this + # JSONL restores a compacted session's whole history instead of only its live rows. + rows = [db.export_session(resolved_session_id, include_inactive=True)] if not rows[0]: raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") else: found = db.search_sessions(source=ns.source, limit=100000) _guard_exports(db, [session["id"] for session in found]) - rows = db.export_all(source=ns.source) + rows = db.export_all(source=ns.source, include_inactive=True) text = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows) if text: text += "\n" diff --git a/hermes_state_portability.py b/hermes_state_portability.py index 1f86d6857c..5ac5be1aff 100644 --- a/hermes_state_portability.py +++ b/hermes_state_portability.py @@ -309,20 +309,24 @@ class SessionPortabilityMixin: "messages": messages, "timings": _export_timings(messages, session_id), } - def export_all(self, source: str = None, include_compacted: bool = False) -> List[Dict[str, Any]]: - """Export all sessions (with messages) as dicts, e.g. for JSONL backup (``include_compacted`` as in - :meth:`export_session`; that display read dedupes per session, so it skips the batched read).""" + def export_all(self, source: str = None, include_compacted: bool = False, + include_inactive: bool = False) -> List[Dict[str, Any]]: + """Export all sessions (with messages) as dicts, e.g. for JSONL backup (flags as in + :meth:`export_session`; that display read dedupes per session, so it skips the batched read). + Backups that go back through :meth:`import_sessions` pass ``include_inactive`` so + compaction-archived turns survive the round trip as archived rows.""" sessions = self.search_sessions(source=source, limit=100000) if include_compacted: - return [self._with_messages(session, True) for session in sessions] + return [self._with_messages(session, True, include_inactive) for session in sessions] messages_by_session = {session["id"]: [] for session in sessions} session_ids = list(messages_by_session) + active_clause = "" if include_inactive else " AND active = 1" # Stay below SQLite's legacy 999-variable limit while replacing the per-session N+1 reads. for start in range(0, len(session_ids), 900): chunk = session_ids[start:start + 900] rows = self._read_all( f"SELECT * FROM messages WHERE session_id IN ({','.join('?' for _ in chunk)}) " - "AND active = 1 ORDER BY session_id, id", + f"{active_clause} ORDER BY session_id, id", chunk, ) for row in rows: diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index 6943aeb7a1..5763b78f3a 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -1401,8 +1401,10 @@ class SessionSessionsMixin: ) return statuses - def assert_export_safe(self, session_id: str, max_messages: Optional[int] = None) -> int: - """Active row count of this segment, or raise SessionExportTooLargeError (the LIMITed subquery + def assert_export_safe(self, session_id: str, max_messages: Optional[int] = None, + include_inactive: bool = False) -> int: + """Row count of this segment — live rows, or every row with ``include_inactive``, matching what + the export materializes — or raise SessionExportTooLargeError (the LIMITed subquery stops once the bound is exceeded). ``None`` resolves ``sessions.max_export_messages``; 0 disables the guard.""" from hermes_state import SessionExportTooLargeError, resolved_max_export_messages @@ -1412,8 +1414,9 @@ class SessionSessionsMixin: raise ValueError("max_messages must be non-negative") if max_messages == 0: return 0 + active_clause = "" if include_inactive else " AND active = 1" row = self._read_one( - "SELECT COUNT(*) FROM (SELECT 1 FROM messages WHERE session_id = ? AND active = 1 LIMIT ?)", + f"SELECT COUNT(*) FROM (SELECT 1 FROM messages WHERE session_id = ?{active_clause} LIMIT ?)", (session_id, max_messages + 1), ) message_count = int(row[0] if row else 0) diff --git a/tests/hermes_cli/test_console_engine.py b/tests/hermes_cli/test_console_engine.py index 04117638cf..b9f1fc2b65 100644 --- a/tests/hermes_cli/test_console_engine.py +++ b/tests/hermes_cli/test_console_engine.py @@ -169,7 +169,7 @@ def test_sessions_export_all_rejects_single_oversized_session( assert result.status == "error" assert "runaway" in result.output - assert "more than 3 active" in result.output + assert "more than 3 exportable" in result.output assert "streaming Export" in result.output assert "max_export_messages" in result.output assert export_all_calls == [] diff --git a/tests/hermes_state/test_session_archived_round_trip.py b/tests/hermes_state/test_session_archived_round_trip.py new file mode 100644 index 0000000000..b9bd51fa76 --- /dev/null +++ b/tests/hermes_state/test_session_archived_round_trip.py @@ -0,0 +1,58 @@ +"""Round-trip export/import must restore archived message state (#122679). + +The JSONL transfer projection feeds ``import_sessions``: rows in-place compaction +archived must come back archived — visible in the display history, never as live +model context — or a restored compacted session silently loses every turn the +summary replaced. ``active``/``compacted`` are the row state; absent flags (older +exports) keep meaning live rows. +""" + +from __future__ import annotations + +import json + +from hermes_state import SessionDB + +STRANDED_ID = "20260823_043331_c93770" + + +def _seed_and_compact(db, turns=4): + db.create_session(STRANDED_ID, source="tui") + db.set_session_title(STRANDED_ID, "Bot Chat") + for i in range(1, turns + 1): + db.append_message(STRANDED_ID, "user", f"question {i}") + db.append_message(STRANDED_ID, "assistant", f"answer {i}") + tail = db.get_messages(STRANDED_ID)[-2:] + db.archive_and_compact( + STRANDED_ID, [{"role": "user", "content": "[summary of turns 1-3]"}, *tail], tail_count=2) + + +def _shape(db, **flags): + return [(m["role"], m["content"], m.get("active", 1), m.get("compacted", 0)) + for m in db.get_messages(STRANDED_ID, **flags)] + + +def test_export_all_round_trips_compacted_history(tmp_path): + src = SessionDB(db_path=tmp_path / "src.db") + dst = SessionDB(db_path=tmp_path / "dst.db") + try: + _seed_and_compact(src) + shown_before = _shape(src, include_compacted=True) + live_before = _shape(src) + all_before = _shape(src, include_inactive=True) + assert len(shown_before) > len(live_before), "compacted turns must be archived, not deleted" + + # The transfer projection through real JSONL (what `sessions export` writes). + payload = json.loads(json.dumps(src.export_all(include_inactive=True))) + + result = dst.import_sessions(payload) + + assert result["ok"] and result["imported"] == 1, result + assert _shape(dst, include_inactive=True) == all_before, "every row must survive with its state" + assert _shape(dst, include_compacted=True) == shown_before, "display history must be unchanged" + assert _shape(dst) == live_before, "archived turns must not re-enter live model context" + # Session counters count live rows only. + assert dst.get_session(STRANDED_ID)["message_count"] == len(live_before) + finally: + src.close() + dst.close()