"""/save md (CLI and gateway) carries the display history; /save json (CLI, gateway, TUI/Desktop) every kept row.""" import asyncio import contextlib import io from datetime import datetime from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest def _compacted_store(path): from hermes_state import SessionDB db = SessionDB(db_path=path) db.create_session("s1", "telegram") for i in range(1, 7): db.append_message("s1", "user", f"question {i}") db.append_message("s1", "assistant", f"answer {i}") tail = [{"role": "user", "content": "question 6"}, {"role": "assistant", "content": "answer 6"}] db.archive_and_compact( "s1", [{"role": "user", "content": "[CONTEXT COMPACTION] summary"}, *tail], watermark=db.get_active_message_watermark("s1"), tail_count=len(tail), ) return db def _cli_save(db, fmt, out): import cli stub = SimpleNamespace(_session_db=db, session_id="s1", conversation_history=[], model="m", session_start=datetime(2026, 1, 1)) printed = io.StringIO() with contextlib.redirect_stdout(printed): cli.HermesCLI.save_conversation(stub, f"/save {fmt} {out}") return out.read_text(encoding="utf-8") if out.exists() else printed.getvalue() def _gateway_save(db, fmt, out): from gateway.config import Platform from gateway.platforms.event import MessageEvent from gateway.run import GatewayRunner from gateway.session import SessionEntry, SessionSource, build_session_key from hermes_state import AsyncSessionDB source = SessionSource(platform=Platform.TELEGRAM, user_id="u1", chat_id="c1", user_name="t", chat_type="dm") runner = object.__new__(GatewayRunner) delivered = {} adapter = MagicMock() adapter.send_document = AsyncMock(side_effect=lambda **kw: delivered.update( text=open(kw["file_path"], encoding="utf-8").read())) runner.adapters, runner._profile_adapters = {Platform.TELEGRAM: adapter}, {} runner.session_store = MagicMock() runner.session_store.get_or_create_session.return_value = SessionEntry( session_key=build_session_key(source), session_id="s1", created_at=datetime.now(), updated_at=datetime.now(), platform=Platform.TELEGRAM, chat_type="dm") runner._session_db = AsyncSessionDB(db) event = MessageEvent(text=f"/save {fmt} {out.name}", source=source, message_id="m1") reply = asyncio.run(runner._handle_save_command(event)) return delivered["text"] if reply == "Export complete." else reply def _tui_save(db, fmt, out, *, host=False, profile=False): """TUI/Desktop ``session.save`` (JSON only); ``host`` forwards it as a control frame to a turn-isolated compute host, whose own session runs the handler in-process; ``profile`` launches the process from another (uncapped) home, so the session's profile config must be the one read.""" import json import os import threading from unittest import mock from tui_gateway import server from tui_gateway.compute_host import ComputeHost assert fmt == "json" def session(): return {"agent": SimpleNamespace(model="m", session_id="s1"), "session_key": "s1", "history": [], "profile_home": str(out.parent), "history_lock": threading.Lock()} parent, child = session(), session() def control(sid, **frame): # the host's own control handler, answered over its stdout wire wire = io.StringIO() with mock.patch.dict(server._sessions, {sid: child}): ComputeHost(stdout=wire, heartbeat_secs=0)._handle_control({"sid": sid, **frame}) return json.loads(wire.getvalue()) launch = {"HERMES_HOME": str(out.parent / "launch")} if profile else {} with mock.patch.dict(server._sessions, {"tui-save": parent}), mock.patch.dict(os.environ, launch), \ mock.patch.object(server, "_session_uses_compute_host", lambda s: host and s is parent), \ mock.patch.object(server, "_send_compute_host_control", control): resp = server._methods["session.save"]("1", {"session_id": "tui-save"}) if "error" in resp: assert resp["error"]["code"] == 4131 # the export-cap refusal, through the host too return resp["error"]["message"] return open(resp["result"]["file"], encoding="utf-8").read() @pytest.mark.parametrize("save", [_cli_save, _gateway_save], ids=["cli", "gateway"]) def test_save_transcript_holds_display_history(tmp_path, monkeypatch, save): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) db = _compacted_store(tmp_path / "state.db") try: text = save(db, "md", tmp_path / "saved.md") finally: db.close() assert [f"answer {i}" in text for i in range(1, 7)] == [True] * 6 @pytest.mark.parametrize("save", [_cli_save, _gateway_save, _tui_save, lambda *a: _tui_save(*a, host=True), lambda *a: _tui_save(*a, profile=True)], ids=["cli", "gateway", "tui", "tui-compute-host", "tui-profile"]) def test_save_json_restores_compacted_history_as_archived(tmp_path, monkeypatch, save): """/save json is the snapshot the dashboard import restores: the turns compaction archived come back in the display history, and stay out of the live context.""" import json from hermes_state import SessionDB monkeypatch.setenv("HERMES_HOME", str(tmp_path)) db = _compacted_store(tmp_path / "state.db") # A model-only live row (a micro-compaction merge) is context, not display history: only the transfer # projection carries it, so the snapshot's ids pin that projection, not the display read. db.append_message("s1", "user", "merged", display_metadata={"model_only": True}) db.append_message("s1", "user", "undone secret") db.rewind_to_message("s1", db.get_messages("s1")[-1]["id"]) def shape(store, **flags): return [(m["role"], m["content"]) for m in store.get_messages("s1", **flags)] try: shown, live = shape(db, include_compacted=True), shape(db) snapshot = json.loads(save(db, "json", tmp_path / "saved.json")) kept = [m["id"] for m in db.get_messages("s1", include_inactive=True) if m["active"] or m["compacted"]] assert [m["id"] for m in snapshot["messages"]] == kept # every kept row, not the deduped display read assert sum(snapshot["timings"]["role_counts"].values()) == len(kept) # timings name no undone row assert {i[k] for i in snapshot["timings"]["intervals"] for k in ("from_message_id", "to_message_id")} <= set(kept) # Like `hermes sessions export`, the in-memory backup is capped per session (the session profile's # sessions.max_export_messages). (tmp_path / "config.yaml").write_text("sessions:\n max_export_messages: 5\n", encoding="utf-8") assert "max_export_messages" in save(db, "json", tmp_path / "capped.json") finally: db.close() restored = SessionDB(db_path=tmp_path / "restored.db") try: assert restored.import_sessions([snapshot])["ok"] assert shape(restored, include_compacted=True) == shown assert shape(restored) == live finally: restored.close()