{"repo":"NousResearch/hermes-agent","public_id":"68196","display_label":"Duplicate transcript on cold resume with rotating compression","summary":"These issues and fixes address a bug where persisted transcript content is duplicated during a cold desktop resume when rotating preflight compression is active. The work focuses on preventing re-appending already-persisted transcript data in that specific scenario.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":68205,"merged_at":"2026-07-21T06:41:32+00:00"},"resolution":{"state":"resolved","by":"pr-68205"},"freshness":{"state":"verified","last_verified_at":"2026-07-21T06:41:32+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":68196,"pr_number":68205,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-21T06:41:32+00:00"}],"members":[{"kind":"issue","number":68196,"title":"Cold Desktop resume + rotating preflight compression duplicates persisted transcript","url":null,"state":"closed"},{"kind":"pr","number":68205,"title":"fix(agent): don't re-append persisted transcript on cold-resume rotating compression (#68196)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/conversation_compression.py","tests/agent/test_rotation_flush_persisted_boundary_68196.py"],"fix_hunks":[{"file":"agent/conversation_compression.py","hunks":["@@ -1236,8 +1236,29 @@ def _release_lock() -> None:\n                     # Flush any un-persisted current-turn messages to the OLD\n                     # session before ending it, so they survive in the preserved\n                     # parent transcript (#47202). (In-place skips this — see above.)\n+                    #\n+                    # Pass the already-durable prefix as conversation_history so\n+                    # the flush skips it by identity (#68196). Preflight\n+                    # compression runs BEFORE the normal turn flush has stamped\n+                    # the cold-resumed history dicts with _DB_PERSISTED_MARKER, so\n+                    # without a boundary _flush_messages_to_session_db treats every\n+                    # restored row as new and re-appends the whole transcript to\n+                    # the parent. turn_context anchors _persist_user_message_idx at\n+                    # the current-turn user message before preflight runs, so\n+                    # messages[:idx] is exactly the persisted prefix; only the\n+                    # current turn's new messages get written.\n+                    current_idx = getattr(agent, \"_persist_user_message_idx\", None)\n+                    persisted_history = (\n+                        messages[:current_idx]\n+                        if isinstance(current_idx, int)\n+                        and 0 <= current_idx <= len(messages)\n+                        else None\n+                    )\n                     try:\n-                        agent._flush_messages_to_session_db(messages)\n+                        agent._flush_messages_to_session_db(\n+                            messages,\n+                            conversation_history=persisted_history,\n+                        )\n                     except Exception:\n                         pass  # best-effort — don't block compression on a flush error\n                     # Propagate title to the new session with auto-numbering"]},{"file":"tests/agent/test_rotation_flush_persisted_boundary_68196.py","hunks":["@@ -0,0 +1,118 @@\n+\"\"\"Regression (#68196): rotating preflight compression must not re-append the\n+already-persisted transcript to the parent session on cold resume.\n+\n+On the first turn after a cold Desktop resume, the stored rows are handed to\n+``run_conversation()`` as ``conversation_history`` and live in the message list\n+as plain dicts that have NOT yet been stamped with ``_DB_PERSISTED_MARKER`` —\n+the normal turn flush (which stamps them) runs *after* preflight compression.\n+\n+The legacy rotation branch in ``agent/conversation_compression.py`` flushes the\n+current turn to the OLD session before ending it (#47202). It used to call\n+``agent._flush_messages_to_session_db(messages)`` with no history boundary, so\n+``_flush_messages_to_session_db`` saw an empty ``history_ids`` set and treated\n+every restored row as new — durably appending the whole transcript to the\n+parent a second time. The fix passes ``messages[:_persist_user_message_idx]``\n+(the already-durable prefix ``turn_context`` anchors before preflight runs) as\n+``conversation_history`` so only the current turn's new messages are written.\n+\n+Without the fix the parent grows to 5 rows (the two originals + a duplicate of\n+both + the new turn). With it the parent holds exactly the two originals plus\n+the single new turn.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+import time\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+from hermes_state import SessionDB\n+\n+\n+def _build_agent_with_db(db: SessionDB, session_id: str):\n+    \"\"\"Build an AIAgent wired to ``db`` and pinned to ``session_id``.\n+\n+    Mirrors the helper in ``test_compression_concurrent_fork.py``: stub the\n+    compressor so it returns deterministic output without an LLM call, and pin\n+    ``compression_in_place=False`` so the legacy rotation path is exercised\n+    regardless of the global default.\n+    \"\"\"\n+    with patch.dict(os.environ, {\"OPENROUTER_API_KEY\": \"[redacted:secret-assignment]\"}):\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            model=\"test/model\",\n+            quiet_mode=True,\n+            session_db=db,\n+            session_id=session_id,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+\n+    compressor = MagicMock()\n+\n+    def _compress(*_a, **_kw):\n+        time.sleep(0.01)\n+        return [\n+            {\"role\": \"user\", \"content\": \"[CONTEXT COMPACTION] summary\"},\n+            {\"role\": \"user\", \"content\": \"tail\"},\n+        ]\n+\n+    compressor.compress.side_effect = _compress\n+    compressor.compression_count = 1\n+    compressor.last_prompt_tokens = 0\n+    compressor.last_completion_tokens = 0\n+    compressor._last_summary_error = None\n+    compressor._last_compress_aborted = False\n+    compressor._last_aux_model_failure_model = None\n+    compressor._last_aux_model_failure_error = None\n+    agent.context_compressor = compressor\n+    agent.compression_in_place = False\n+    return agent\n+\n+\n+def _contents(rows):\n+    return [r.get(\"content\") for r in rows]\n+\n+\n+def test_rotation_flush_does_not_duplicate_persisted_prefix(tmp_path: Path) -> None:\n+    \"\"\"Cold-resume + rotating preflight compression keeps the parent transcript\n+    at (persisted prefix + one new turn) — no second copy of the durable rows.\"\"\"\n+    db = SessionDB(db_path=tmp_path / \"state.db\")\n+\n+    parent_sid = \"COLD_RESUME_PARENT\"\n+    db.create_session(parent_sid, source=\"desktop\")\n+\n+    # Two durable rows already in the parent.\n+    db.append_message(parent_sid, \"user\", \"persisted question\")\n+    db.append_message(parent_sid, \"assistant\", \"persisted answer\")\n+\n+    # Cold resume: the stored rows come back as plain dicts, unstamped, and the\n+    # live turn appends one new user message on top.\n+    loaded = db.get_messages_as_conversation(parent_sid)\n+    assert _contents(loaded) == [\"persisted question\", \"persisted answer\"]\n+    messages = [*loaded, {\"role\": \"user\", \"content\": \"new turn\"}]\n+\n+    agent = _build_agent_with_db(db, parent_sid)\n+    # turn_context anchors this at the current-turn user message before preflight\n+    # compression runs; emulate that anchor.\n+    agent._persist_user_message_idx = len(messages) - 1\n+\n+    agent._compress_context(messages, \"sys\", approx_tokens=[redacted:secret-assignment]\n+\n+    # The flush at the rotation boundary lands on the OLD (parent) session,\n+    # which is then ended. Read it back verbatim (include_inactive to be robust\n+    # to the end_session bookkeeping).\n+    parent_rows = db.get_messages_as_conversation(parent_sid, include_inactive=True)\n+    contents = _contents(parent_rows)\n+\n+    assert contents.count(\"persisted question\") == 1, (\n+        \"Rotation flush re-appended the already-persisted prefix to the parent \"\n+        f\"(#68196). Parent transcript is {contents!r}; expected the two durable \"\n+        \"rows plus only the new turn.\"\n+    )\n+    assert contents.count(\"persisted answer\") == 1\n+    assert contents == [\"persisted question\", \"persisted answer\", \"new turn\"]\n"]}]},{"kind":"pr","number":68288,"title":"fix(agent/comp/desktop): prevent duplicate transcript rows on cold resume + rotating compression","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68205","number":68205}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}