In-place compaction is the default. It soft-archives every earlier row of a session under the same id (active = 0, compacted = 1), and Desktop and the dashboard still show those turns. The md/qmd export read the session through export_session -> get_messages with the default live-only clause, so it wrote only the compaction summary and the carried tail. verify_export_file then compared the file with that same dict, and delete_session removed every row of the session, including the archived turns that never reached the file. The md/qmd export now reads the display history (include_compacted), for a single session and for --lineage logical. export_session and export_session_lineage take include_compacted, off by default: import_sessions inserts every message as live context, so the JSON export and stranded-session adoption keep reading live rows only. The other transcripts people read had the same hole without the delete: `/save md` and `/save html` (CLI and gateway), `sessions export --format html` (one session or all of them) and `--only user-prompts` each held 1 of 6 answers on a six-turn session after one compaction. They now read the display history too (SAVE_TRANSCRIPT_FORMATS; export_all gains include_compacted and reads per session then, since the display read dedupes per session). `/save json`, JSONL and the dashboard's JSON export stay live-only for the import reason above. Before deleting, the verify step also re-counts the store's display rows for every session the file covers and refuses on a mismatch. A message that lands while the files are written, or a later export change that reads a narrower view, now refuses the delete instead of being removed unseen. Like the adoption retire loop, the re-count runs just before delete_session, not inside its transaction. Rewind rows (undone turns, the superseded originals of a carried tail) are still deleted without being exported, as `hermes sessions delete` does: they are not part of the history the session shows. Measured through the real CLI on a session with 6 turns and one default in-place compaction (15 rows, 13 shown): before, 3 messages were exported and all 15 rows deleted, with answers 1-5 missing from the file; after, 13 messages are exported in display order, then deleted. (cherry picked from commit adeaff1e33f1ae2b8a066ef2374bb29ade50b3b8)
68 lines
2.9 KiB
Python
68 lines
2.9 KiB
Python
"""/save md|html is a transcript: after in-place compaction it still holds every turn the chat shows.
|
|
/save json stays the live rows import_sessions restores."""
|
|
import asyncio
|
|
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))
|
|
cli.HermesCLI.save_conversation(stub, f"/save {fmt} {out}")
|
|
return out.read_text(encoding="utf-8")
|
|
|
|
|
|
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")
|
|
assert asyncio.run(runner._handle_save_command(event)) == "Export complete."
|
|
return delivered["text"]
|
|
|
|
|
|
@pytest.mark.parametrize("save", [_cli_save, _gateway_save], ids=["cli", "gateway"])
|
|
@pytest.mark.parametrize("fmt, expected", [("md", 6), ("html", 6), ("json", 1)])
|
|
def test_save_transcript_holds_every_turn_the_chat_shows(tmp_path, monkeypatch, save, fmt, expected):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
db = _compacted_store(tmp_path / "state.db")
|
|
try:
|
|
text = save(db, fmt, tmp_path / f"saved.{fmt}")
|
|
finally:
|
|
db.close()
|
|
assert sum(f"answer {i}" in text for i in range(1, 7)) == expected
|