"""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", ]