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)
138 lines
3.5 KiB
Python
138 lines
3.5 KiB
Python
import json
|
|
import sys
|
|
|
|
from hermes_cli.session_export import export_record_count, render_sessions_export
|
|
from hermes_cli.session_export_html import (
|
|
_generate_messages_html,
|
|
generate_multi_session_html_export,
|
|
)
|
|
|
|
|
|
def _sample_session():
|
|
return {
|
|
"id": "sess-123",
|
|
"source": "cli",
|
|
"model": "test/model",
|
|
"title": "Debug auth flow",
|
|
"started_at": 1700000000,
|
|
"message_count": 5,
|
|
"messages": [
|
|
{
|
|
"id": 1,
|
|
"role": "system",
|
|
"content": "hidden system context",
|
|
"timestamp": 1700000000,
|
|
},
|
|
{
|
|
"id": 2,
|
|
"role": "user",
|
|
"content": "Why is login broken?",
|
|
"timestamp": 1700000001,
|
|
"platform_message_id": "evt-2",
|
|
},
|
|
{
|
|
"id": 3,
|
|
"role": "assistant",
|
|
"content": "I will inspect the auth middleware.",
|
|
"timestamp": 1700000002,
|
|
},
|
|
{
|
|
"id": 4,
|
|
"role": "tool",
|
|
"tool_name": "read_file",
|
|
"content": "def redirect_after_login(): pass",
|
|
"timestamp": 1700000003,
|
|
},
|
|
{
|
|
"id": 5,
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Only show me the prompts."}],
|
|
"timestamp": 1700000004,
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_html_export_escapes_tool_call_names():
|
|
payload = '<img src=x onerror="alert(document.domain)">'
|
|
|
|
rendered = _generate_messages_html(
|
|
[
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {"name": payload, "arguments": "<b>x</b>"},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
)
|
|
|
|
assert payload not in rendered
|
|
assert '<img src=x onerror="alert(document.domain)">' in rendered
|
|
assert "<b>x</b>" in rendered
|
|
|
|
|
|
|
|
|
|
def test_export_record_count_switches_unit_for_prompt_only_exports():
|
|
assert export_record_count([_sample_session()]) == (1, "session")
|
|
assert export_record_count([_sample_session()], only="user-prompts") == (
|
|
2,
|
|
"prompt",
|
|
)
|
|
|
|
|
|
def test_sessions_export_cli_prompt_only_stdout(monkeypatch, capsys):
|
|
import hermes_cli.main as main_mod
|
|
import hermes_state
|
|
|
|
captured = {}
|
|
|
|
class FakeDB:
|
|
def resolve_session_id(self, session_id):
|
|
captured["resolved_from"] = session_id
|
|
return "sess-123"
|
|
|
|
def export_session(self, session_id, include_compacted=False):
|
|
captured["exported"] = session_id
|
|
return _sample_session()
|
|
|
|
def close(self):
|
|
captured["closed"] = True
|
|
|
|
monkeypatch.setattr(hermes_state, "SessionDB", lambda *args, **kwargs: FakeDB())
|
|
monkeypatch.setattr(
|
|
sys,
|
|
"argv",
|
|
["hermes", "sessions", "export", "-", "--session-id", "sess", "--only", "user-prompts"],
|
|
)
|
|
|
|
main_mod.main()
|
|
|
|
output = capsys.readouterr().out
|
|
records = [json.loads(line) for line in output.splitlines()]
|
|
assert [record["text"] for record in records] == [
|
|
"Why is login broken?",
|
|
"Only show me the prompts.",
|
|
]
|
|
assert captured == {
|
|
"resolved_from": "sess",
|
|
"exported": "sess-123",
|
|
"closed": True,
|
|
}
|
|
|
|
|