Files
hermes-agent/tests/agent/test_session_rotation_flush_cold_resume_68454.py
T
kshitijk4poor 016ba66176 fix: stamp _db_persisted at row load time so resumed transcripts never re-append (#92231)
Resumed sessions loaded message dicts from state.db WITHOUT the
_DB_PERSISTED_MARKER, so any flush that lost the identity boundary
(compression durable-snapshot adoption, incremental tool-call persists,
rotation preflight on cold resume) re-appended the ENTIRE loaded
transcript as new rows. Compression cycles then doubled the copies:
the incident session grew 998 -> 1995 -> 3990 -> 7981 rows across
three aborted rotations (15,962 active rows, only 472 distinct).

Fix at the architectural chokepoint: SessionDB._rows_to_conversation
(shared by get_messages_as_conversation and get_resume_conversations)
now stamps the marker at row materialization time - a dict built FROM
a durable row is persisted by construction, regardless of which caller
loads it or how the list is later handed to a flush.

Safety:
- Wire-safe: every transport strips underscore-prefixed keys before
  the API request (chat_completion_helpers, anthropic_adapter), same
  contract as the existing _row_id stamp in the same function.
- Rotation handoffs still write: compression's assembly copies strip
  the marker (_fresh_compaction_message_copy + the terminal
  _strip_persistence_markers sweep), so compacted transcripts still
  flush to the child session (#57491 invariant preserved).
- Branch/seed copies unaffected: /branch and _persist_branch_seed
  build fresh field-projected dicts and write via append_messages_batch
  directly, not through the marker-gated flush.

Tests: new regression suite (marker sync, load stamping, 3-cycle
amplification repro, new-tail write guard, compaction-copy handoff);
updated the #68454 control test that asserted the old double-write
behavior and the ACP restore shape test.
2026-08-23 13:34:32 +05:30

118 lines
4.4 KiB
Python

"""Regression (#68454): /new, /resume, /branch must not re-append cold-resumed
transcript rows when flushing before session rotation.
Sibling of #68196 / #68205 (preflight compression path). Cold resume loads
plain dicts without ``_DB_PERSISTED_MARKER``. If the user rotates immediately
via /new, /resume, or /branch, the old-session flush must pass
``conversation_history=`` so identity skip treats the loaded prefix as durable.
These tests bind the real ``AIAgent._flush_messages_to_session_db`` methods onto
a lightweight stand-in so construction never hits network model-metadata
lookups (offline CI / hung OpenRouter).
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from hermes_state import SessionDB
from run_agent import AIAgent
def _make_flush_agent(db: SessionDB, session_id: str):
"""Minimal agent shell that owns the real flush implementation."""
agent = SimpleNamespace(
_session_db=db,
_session_db_created=True,
_persist_disabled=False,
session_id=session_id,
_session_persist_lock=None,
_flushed_db_message_ids=set(),
_flushed_db_message_session_id=None,
_last_flushed_db_idx=0,
_persist_user_message_idx=None,
_persist_user_message_override=None,
_persist_user_message_timestamp=None,
_pending_cli_user_message=None,
)
agent._ensure_db_session = lambda: None
agent._flush_messages_to_session_db = (
AIAgent._flush_messages_to_session_db.__get__(agent, AIAgent)
)
agent._flush_messages_to_session_db_unlocked = (
AIAgent._flush_messages_to_session_db_unlocked.__get__(agent, AIAgent)
)
return agent
def _contents(rows):
return [r.get("content") for r in rows]
def test_rotation_flush_without_history_boundary_is_safe(tmp_path: Path) -> None:
"""Bare flush of cold-resumed rows must NOT double-write (#68454 → #92231).
Historically this was a control test asserting the double-write: loaded
rows carried no ``_DB_PERSISTED_MARKER``, so a flush without
``conversation_history=`` re-appended the whole transcript. Since #92231
the loaders stamp the marker at row-materialization time, so even the
"wrong" call shape (no history boundary) is idempotent.
"""
db = SessionDB(db_path=tmp_path / "state.db")
sid = "COLD_ROTATE_DUP"
db.create_session(sid, source="cli")
db.append_message(sid, "user", "persisted question")
db.append_message(sid, "assistant", "persisted answer")
loaded = db.get_messages_as_conversation(sid)
agent = _make_flush_agent(db, sid)
agent._flush_messages_to_session_db(loaded) # missing conversation_history=
rows = db.get_messages_as_conversation(sid, include_inactive=True)
assert _contents(rows) == ["persisted question", "persisted answer"]
def test_rotation_flush_with_history_boundary_is_noop(tmp_path: Path) -> None:
"""Fix shape used by /new, /resume, /branch: pass conversation_history=self list."""
db = SessionDB(db_path=tmp_path / "state.db")
sid = "COLD_ROTATE_OK"
db.create_session(sid, source="cli")
db.append_message(sid, "user", "persisted question")
db.append_message(sid, "assistant", "persisted answer")
loaded = db.get_messages_as_conversation(sid)
agent = _make_flush_agent(db, sid)
agent._flush_messages_to_session_db(
loaded,
conversation_history=loaded,
)
rows = db.get_messages_as_conversation(sid, include_inactive=True)
assert _contents(rows) == ["persisted question", "persisted answer"]
def test_rotation_flush_writes_only_new_tail(tmp_path: Path) -> None:
"""If a turn added messages after cold resume, only the tail is written."""
db = SessionDB(db_path=tmp_path / "state.db")
sid = "COLD_ROTATE_TAIL"
db.create_session(sid, source="cli")
db.append_message(sid, "user", "persisted question")
db.append_message(sid, "assistant", "persisted answer")
loaded = db.get_messages_as_conversation(sid)
live = [*loaded, {"role": "user", "content": "new unpersisted turn"}]
agent = _make_flush_agent(db, sid)
agent._flush_messages_to_session_db(
live,
conversation_history=loaded,
)
rows = db.get_messages_as_conversation(sid, include_inactive=True)
assert _contents(rows) == [
"persisted question",
"persisted answer",
"new unpersisted turn",
]