Files
hermes-agent/tests/hermes_state/test_session_archived_round_trip.py
kshitijk4poor deb7bc7343 fix(sessions): import coerces archived-row flags and splits live rows once
A transfer row's active/compacted flags were read by truthiness, so a
hand-edited or foreign JSONL row with "active": "0" (truthy string) imported
live - putting compaction-archived turns back into model context - and
"active": null archived a live row. Coerce both flags with the same
_coerce_or(..., int, default) used for the int session columns: a missing,
null, or unparsable active flag means live (older exports carry none), and
compacted defaults to 0.

The same pass partitions rows into live and archived up front, replacing the
id()-set complement, the dead "_row_id" guard (_insert_message_rows always
sets it), and the subtract-and-reparse counter fixup: message_count and
tool_call_count now come straight from the live rows, with identical values
for well-formed exports. The kept round-trip test re-imports its payload with
stringified/null flags and asserts every row keeps its state.

Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
2026-09-26 22:27:13 +05:30

71 lines
3.0 KiB
Python

"""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)
# Hand-edited/foreign JSONL: string flags ("0" is truthy) and null for live rows must not
# flip archived rows live or live rows archived.
for msg in payload[0]["messages"]:
msg["active"], msg["compacted"] = (str(msg["active"]) if not msg["active"] else None,
str(msg["compacted"]))
edited = SessionDB(db_path=tmp_path / "edited.db")
try:
assert edited.import_sessions(payload)["ok"]
assert _shape(edited, include_inactive=True) == all_before
finally:
edited.close()
finally:
src.close()
dst.close()