{"repo":"NousResearch/hermes-agent","public_id":"43936","display_label":"Assistant messages dropped from session DB on interrupted turns","summary":"The members concern assistant messages not being persisted to the session database (state.db), particularly when a turn is interrupted, and the lack of a .jsonl fallback. The three pull requests propose changes to how repaired-turn responses, delivered assistant text, and session-DB flushing are persisted.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":46071,"merged_at":"2026-06-14T10:20:26+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-14T10:20:26+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":43936,"pr_number":46071,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-14T10:20:26+00:00"},{"issue_number":46053,"pr_number":46071,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-14T10:20:26+00:00"}],"members":[{"kind":"issue","number":43936,"title":"[Bug]:  State.db drops assistant messages on interrupt; .jsonl session log no longer available as fallback","url":null,"state":"closed"},{"kind":"pr","number":43962,"title":"fix(agent): persist delivered assistant text on interrupted turns","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":45277,"title":"fix(agent): identity-based session-DB flush — stop positional assistant drops (#43936)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46071","number":46071}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":46071,"title":"fix(agent): persist repaired-turn responses","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_compression_persistence.py","tests/run_agent/test_identity_flush.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -1548,9 +1548,10 @@ def _repair_message_sequence(self, messages: List[Dict]) -> int:\n     def _flush_messages_to_session_db(self, messages: List[Dict], conversation_history: List[Dict] = None):\n         \"\"\"Persist any un-flushed messages to the SQLite session store.\n \n-        Uses _last_flushed_db_idx to track which messages have already been\n-        written, so repeated calls (from multiple exit paths) only write\n-        truly new messages — preventing the duplicate-write bug (#860).\n+        Uses per-session message identity tracking so repeated calls (from\n+        multiple exit paths) only write truly new messages — preventing the\n+        duplicate-write bug (#860) without relying on positional slices that\n+        can drift after message-sequence repair.\n         \"\"\"\n         if not self._session_db:\n             return","@@ -1559,14 +1560,41 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo\n             # Retry row creation if the earlier attempt failed transiently.\n             if not self._session_db_created:\n                 self._ensure_db_session()\n-            start_idx = len(conversation_history) if conversation_history else 0\n-            # Guard against the flush cursor overshooting the message list.\n-            # This can happen when repair_message_sequence compacts the list\n-            # (merging consecutive users, dropping stray tools) after the\n-            # cursor was set.  Fall back to start_idx so we don't skip\n-            # persisting the assistant/tool chain (#44837).\n-            flush_from = max(start_idx, min(self._last_flushed_db_idx, len(messages)))\n-            for msg in messages[flush_from:]:\n+            # Positional flushing used to slice at\n+            # max(len(conversation_history), _last_flushed_db_idx). That\n+            # assumes the live `messages` list is the original history plus a\n+            # new tail. repair_message_sequence can shrink/merge the history\n+            # copy before the final flush, making len(conversation_history)\n+            # larger than len(messages); the slice is then empty and delivered\n+            # assistant responses never reach state.db (#46053).\n+            #\n+            # Track object identities instead. `messages` is a shallow copy of\n+            # `conversation_history`, so history dicts are skipped by identity,\n+            # and new dicts appended during this turn are written once even if\n+            # repair compacts the list around them.\n+            current_session_id = getattr(self, \"session_id\", None)\n+            flushed_session_id = getattr(self, \"_flushed_db_message_session_id\", None)\n+            if flushed_session_id != current_session_id or self._last_flushed_db_idx == 0:\n+                self._flushed_db_message_ids = set()\n+                self._flushed_db_message_session_id = current_session_id\n+            flushed_ids = getattr(self, \"_flushed_db_message_ids\", None)\n+            if not isinstance(flushed_ids, set):\n+                flushed_ids = set()\n+                self._flushed_db_message_ids = flushed_ids\n+            history_ids = {\n+                id(item) for item in (conversation_history or [])\n+                if isinstance(item, dict)\n+            }\n+\n+            for msg in messages:\n+                if not isinstance(msg, dict):\n+                    continue\n+                msg_id = id(msg)\n+                if msg_id in flushed_ids:\n+                    continue\n+                if msg_id in history_ids:\n+                    flushed_ids.add(msg_id)\n+                    continue\n                 role = msg.get(\"role\", \"unknown\")\n                 content = msg.get(\"content\")\n                 # Persist multimodal tool results as their text summary only —","@@ -1605,6 +1633,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo\n                     codex_reasoning_items=msg.get(\"codex_reasoning_items\") if role == \"assistant\" else None,\n                     codex_message_items=msg.get(\"codex_message_items\") if role == \"assistant\" else None,\n                 )\n+                flushed_ids.add(msg_id)\n             self._last_flushed_db_idx = len(messages)\n         except Exception as e:\n             logger.warning(\"Session DB append_message failed: %s\", e)"]},{"file":"tests/run_agent/test_compression_persistence.py","hunks":["@@ -101,7 +101,7 @@ def test_flush_after_compression_with_long_history(self):\n             )\n \n     def test_flush_with_stale_history_loses_messages(self):\n-        \"\"\"Demonstrates the bug condition: stale conversation_history causes data loss.\"\"\"\n+        \"\"\"Stale conversation_history no longer causes data loss.\"\"\"\n         from hermes_state import SessionDB\n \n         with tempfile.TemporaryDirectory() as tmpdir:","@@ -120,17 +120,14 @@ def test_flush_with_stale_history_loses_messages(self):\n                 {\"role\": \"assistant\", \"content\": \"continuing...\"},\n             ]\n \n-            # Bug: passing a conversation_history longer than compressed messages\n+            # Stale history longer than messages: the old positional flush\n+            # sliced past the end and dropped both messages (#46053).\n             stale_history = [{\"role\": \"user\", \"content\": f\"msg{i}\"} for i in range(100)]\n             agent._flush_messages_to_session_db(compressed, stale_history)\n \n             rows = db.get_messages(\"new-session\")\n-            # With the stale history, flush_from = max(100, 0) = 100\n-            # But compressed only has 2 entries → messages[100:] = empty\n-            assert len(rows) == 0, (\n-                \"Expected 0 messages with stale conversation_history \"\n-                \"(this test verifies the bug condition exists)\"\n-            )\n+            assert len(rows) == 2\n+            assert [row[\"content\"] for row in rows] == [\"summary\", \"continuing...\"]\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/run_agent/test_identity_flush.py","hunks":["@@ -0,0 +1,150 @@\n+\"\"\"Regression tests for identity-based SessionDB flushing (#46053).\"\"\"\n+\n+import os\n+import tempfile\n+from pathlib import Path\n+from unittest.mock import patch\n+\n+SESSION_ID = \"test-identity-flush\"\n+\n+\n+def _make_agent(session_db, session_id=SESSION_ID):\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=session_db,\n+            session_id=session_id,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+    agent._ensure_db_session()\n+    return agent\n+\n+\n+def _contents(db, session_id=SESSION_ID):\n+    return [row[\"content\"] for row in db.get_messages(session_id)]\n+\n+\n+class TestIdentityFlush:\n+    def test_repair_shrunk_messages_below_history_length_still_persists_assistant(self):\n+        \"\"\"When repair shortens messages below conversation_history, don't slice empty.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db = SessionDB(db_path=Path(tmpdir) / \"t.db\")\n+            try:\n+                agent = _make_agent(db)\n+\n+                # Simulate history already loaded from state.db.\n+                history = [{\"role\": \"user\", \"content\": f\"u{i}\"} for i in range(6)]\n+                for msg in history:\n+                    db.append_message(\n+                        session_id=SESSION_ID,\n+                        role=msg[\"role\"],\n+                        content=msg[\"content\"],\n+                    )\n+\n+                # repair_message_sequence merged the six history rows into one\n+                # dict before this turn appended the new user/assistant pair.\n+                messages = [\n+                    {\"role\": \"user\", \"content\": \"\\n\\n\".join(f\"u{i}\" for i in range(6))},\n+                    {\"role\": \"user\", \"content\": \"new question\"},\n+                    {\"role\": \"assistant\", \"content\": \"new answer\"},\n+                ]\n+                assert len(history) > len(messages)\n+\n+                # The old positional flush computed flush_from >= len(messages)\n+                # and dropped the assistant. Identity flush persists new dicts.\n+                agent._last_flushed_db_idx = len(history)\n+                agent._flush_messages_to_session_db(messages, history)\n+\n+                contents = _contents(db)\n+                assert \"new question\" in contents\n+                assert \"new answer\" in contents\n+            finally:\n+                db.close()\n+\n+    def test_overlapping_turn_stale_cursor_does_not_drop_assistant(self):\n+        \"\"\"A stale cached-agent cursor must not suppress this turn's new dicts.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db = SessionDB(db_path=Path(tmpdir) / \"t.db\")\n+            try:\n+                agent = _make_agent(db)\n+                history = [\n+                    {\"role\": \"user\", \"content\": \"old question\"},\n+                    {\"role\": \"assistant\", \"content\": \"old answer\"},\n+                ]\n+                for msg in history:\n+                    db.append_message(\n+                        session_id=SESSION_ID,\n+                        role=msg[\"role\"],\n+                        content=msg[\"content\"],\n+                    )\n+\n+                messages = history + [\n+                    {\"role\": \"user\", \"content\": \"current question\"},\n+                    {\"role\": \"assistant\", \"content\": \"current answer\"},\n+                ]\n+                agent._last_flushed_db_idx = len(messages) + 10\n+                agent._flush_messages_to_session_db(messages, history)\n+\n+                assert _contents(db) == [\n+                    \"old question\",\n+                    \"old answer\",\n+                    \"current question\",\n+                    \"current answer\",\n+                ]\n+            finally:\n+                db.close()\n+\n+    def test_repeated_flush_same_turn_writes_once(self):\n+        \"\"\"Identity tracking preserves #860 same-turn dedup behavior.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db = SessionDB(db_path=Path(tmpdir) / \"t.db\")\n+            try:\n+                agent = _make_agent(db)\n+                messages = [{\"role\": \"user\", \"content\": \"q\"}]\n+\n+                agent._flush_messages_to_session_db(messages, [])\n+                messages.append({\"role\": \"assistant\", \"content\": \"a\"})\n+                agent._flush_messages_to_session_db(messages, [])\n+                agent._flush_messages_to_session_db(messages, [])\n+\n+                assert _contents(db) == [\"q\", \"a\"]\n+            finally:\n+                db.close()\n+\n+    def test_cursor_reset_starts_new_turn_identity_window(self):\n+        \"\"\"Gateway resets _last_flushed_db_idx=0 before a cached-agent turn.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db = SessionDB(db_path=Path(tmpdir) / \"t.db\")\n+            try:\n+                agent = _make_agent(db)\n+                first_turn = [\n+                    {\"role\": \"user\", \"content\": \"q1\"},\n+                    {\"role\": \"assistant\", \"content\": \"a1\"},\n+                ]\n+                agent._flush_messages_to_session_db(first_turn, [])\n+\n+                history = [dict(m) for m in first_turn]\n+                second_turn = history + [\n+                    {\"role\": \"user\", \"content\": \"q2\"},\n+                    {\"role\": \"assistant\", \"content\": \"a2\"},\n+                ]\n+                agent._last_flushed_db_idx = 0\n+                agent._flush_messages_to_session_db(second_turn, history)\n+\n+                assert _contents(db) == [\"q1\", \"a1\", \"q2\", \"a2\"]\n+            finally:\n+                db.close()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}