{"repo":"NousResearch/hermes-agent","public_id":"860","display_label":"SQLite session transcript message duplication in gateway sessions","summary":"All three members concern duplicate messages being written to SQLite session transcripts, with one reporting 3-4x token inflation. Two are fix proposals, one using a _last_flushed_db_idx approach to deduplicate session writes; the titles alone do not show whether any fix is complete or merged.","subsystem":"gateway","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":873,"merged_at":"2026-03-10T22:29:28+00:00"},"resolution":{"state":"resolved","by":"pr-873"},"freshness":{"state":"stale","last_verified_at":"2026-03-10T22:29:28+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":860,"pr_number":873,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-10T22:29:28+00:00"}],"members":[{"kind":"issue","number":860,"title":"bug: SQLite session transcript accumulates duplicate messages (3-4x token inflation)","url":null,"state":"closed"},{"kind":"pr","number":870,"title":"fix: deduplicate SQLite session writes via _last_flushed_db_idx","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#873","number":873}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":873,"title":"fix: eliminate 3x SQLite message duplication in gateway sessions","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","gateway/session.py","run_agent.py","tests/test_860_dedup.py","tests/tools/test_interrupt.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -1322,6 +1322,11 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                         {\"role\": \"assistant\", \"content\": response, \"timestamp\": ts}\n                     )\n             else:\n+                # The agent already persisted these messages to SQLite via\n+                # _flush_messages_to_session_db(), so skip the DB write here\n+                # to prevent the duplicate-write bug (#860).  We still write\n+                # to JSONL for backward compatibility and as a backup.\n+                agent_persisted = self._session_db is not None\n                 for msg in new_messages:\n                     # Skip system messages (they're rebuilt each run)\n                     if msg.get(\"role\") == \"system\":","@@ -1329,7 +1334,8 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                     # Add timestamp to each message for debugging\n                     entry = {**msg, \"timestamp\": ts}\n                     self.session_store.append_to_transcript(\n-                        session_entry.session_id, entry\n+                        session_entry.session_id, entry,\n+                        skip_db=agent_persisted,\n                     )\n             \n             # Update session"]},{"file":"gateway/session.py","hunks":["@@ -677,10 +677,17 @@ def get_transcript_path(self, session_id: str) -> Path:\n         \"\"\"Get the path to a session's legacy transcript file.\"\"\"\n         return self.sessions_dir / f\"{session_id}.jsonl\"\n     \n-    def append_to_transcript(self, session_id: str, message: Dict[str, Any]) -> None:\n-        \"\"\"Append a message to a session's transcript (SQLite + legacy JSONL).\"\"\"\n-        # Write to SQLite\n-        if self._db:\n+    def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None:\n+        \"\"\"Append a message to a session's transcript (SQLite + legacy JSONL).\n+\n+        Args:\n+            skip_db: When True, only write to JSONL and skip the SQLite write.\n+                     Used when the agent already persisted messages to SQLite\n+                     via its own _flush_messages_to_session_db(), preventing\n+                     the duplicate-write bug (#860).\n+        \"\"\"\n+        # Write to SQLite (unless the agent already handled it)\n+        if self._db and not skip_db:\n             try:\n                 self._db.append_message(\n                     session_id=session_id,"]},{"file":"run_agent.py","hunks":["@@ -497,6 +497,7 @@ def __init__(\n         \n         # SQLite session store (optional -- provided by CLI or gateway)\n         self._session_db = session_db\n+        self._last_flushed_db_idx = 0  # tracks DB-write cursor to prevent duplicate writes\n         if self._session_db:\n             try:\n                 self._session_db.create_session(","@@ -802,45 +803,19 @@ def _persist_session(self, messages: List[Dict], conversation_history: List[Dict\n         self._save_session_log(messages)\n         self._flush_messages_to_session_db(messages, conversation_history)\n \n-    def _log_msg_to_db(self, msg: Dict):\n-        \"\"\"Log a single message to SQLite immediately. Called after each messages.append().\"\"\"\n-        if not self._session_db:\n-            return\n-        try:\n-            role = msg.get(\"role\", \"unknown\")\n-            content = msg.get(\"content\")\n-            tool_calls_data = None\n-            if hasattr(msg, \"tool_calls\") and msg.tool_calls:\n-                tool_calls_data = [\n-                    {\"name\": tc.function.name, \"arguments\": tc.function.arguments}\n-                    for tc in msg.tool_calls\n-                ]\n-            elif isinstance(msg.get(\"tool_calls\"), list):\n-                tool_calls_data = msg[\"tool_calls\"]\n-            self._session_db.append_message(\n-                session_id=self.session_id,\n-                role=role,\n-                content=content,\n-                tool_name=msg.get(\"tool_name\"),\n-                tool_calls=tool_calls_data,\n-                tool_call_id=msg.get(\"tool_call_id\"),\n-                finish_reason=msg.get(\"finish_reason\"),\n-            )\n-        except Exception as e:\n-            logger.debug(\"Session DB log_msg failed: %s\", e)\n-\n     def _flush_messages_to_session_db(self, messages: List[Dict], conversation_history: List[Dict] = None):\n-        \"\"\"Persist any un-logged messages to the SQLite session store.\n+        \"\"\"Persist any un-flushed messages to the SQLite session store.\n \n-        Called both at the normal end of run_conversation and from every early-\n-        return path so that tool calls, tool responses, and assistant messages\n-        are never lost even when the conversation errors out.\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         \"\"\"\n         if not self._session_db:\n             return\n         try:\n             start_idx = len(conversation_history) if conversation_history else 0\n-            for msg in messages[start_idx:]:\n+            flush_from = max(start_idx, self._last_flushed_db_idx)\n+            for msg in messages[flush_from:]:\n                 role = msg.get(\"role\", \"unknown\")\n                 content = msg.get(\"content\")\n                 tool_calls_data = None","@@ -860,6 +835,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo\n                     tool_call_id=msg.get(\"tool_call_id\"),\n                     finish_reason=msg.get(\"finish_reason\"),\n                 )\n+            self._last_flushed_db_idx = len(messages)\n         except Exception as e:\n             logger.debug(\"Session DB append_message failed: %s\", e)\n ","@@ -2689,6 +2665,8 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token\n                     except (ValueError, Exception) as e:\n                         logger.debug(\"Could not propagate title on compression: %s\", e)\n                 self._session_db.update_system_prompt(self.session_id, new_system_prompt)\n+                # Reset flush cursor — new session starts with no messages written\n+                self._last_flushed_db_idx = 0\n             except Exception as e:\n                 logger.debug(\"Session DB compression split failed: %s\", e)\n ","@@ -2712,7 +2690,6 @@ def _execute_tool_calls(self, assistant_message, messages: list, effective_task_\n                         \"tool_call_id\": skipped_tc.id,\n                     }\n                     messages.append(skip_msg)\n-                    self._log_msg_to_db(skip_msg)\n                 break\n \n             function_name = tool_call.function.name","@@ -2921,7 +2898,6 @@ def _execute_tool_calls(self, assistant_message, messages: list, effective_task_\n                 \"tool_call_id\": tool_call.id\n             }\n             messages.append(tool_msg)\n-            self._log_msg_to_db(tool_msg)\n \n             if not self.quiet_mode:\n                 response_preview = function_result[:self.log_prefix_chars] + \"...\" if len(function_result) > self.log_prefix_chars else function_result","@@ -2938,7 +2914,6 @@ def _execute_tool_calls(self, assistant_message, messages: list, effective_task_\n                         \"tool_call_id\": skipped_tc.id\n                     }\n                     messages.append(skip_msg)\n-                    self._log_msg_to_db(skip_msg)\n                 break\n \n             if self.tool_delay > 0 and i < len(assistant_message.tool_calls):","@@ -3169,7 +3144,6 @@ def run_conversation(\n         # Add user message\n         user_msg = {\"role\": \"user\", \"content\": user_message}\n         messages.append(user_msg)\n-        self._log_msg_to_db(user_msg)\n         \n         if not self.quiet_mode:\n             print(f\"💬 Starting conversation: '{user_message[:60]}{'...' if len(user_message) > 60 else ''}'\")","@@ -3572,7 +3546,6 @@ def run_conversation(\n                                 length_continue_retries += 1\n                                 interim_msg = self._build_assistant_message(assistant_message, finish_reason)\n                                 messages.append(interim_msg)\n-                                self._log_msg_to_db(interim_msg)\n                                 if assistant_message.content:\n                                     truncated_response_prefix += assistant_message.content\n ","@@ -3590,7 +3563,6 @@ def run_conversation(\n                                         ),\n                                     }\n                                     messages.append(continue_msg)\n-                                    self._log_msg_to_db(continue_msg)\n                                     self._session_messages = messages\n                                     self._save_session_log(messages)\n                                     restart_with_length_continuation = True","@@ -4063,7 +4035,6 @@ def run_conversation(\n                         )\n                         if not duplicate_interim:\n                             messages.append(interim_msg)\n-                            self._log_msg_to_db(interim_msg)\n \n                     if self._codex_incomplete_retries < 3:\n                         if not self.quiet_mode:","@@ -4114,7 +4085,6 @@ def run_conversation(\n                         print(f\"{self.log_prefix}⚠️  Unknown tool '{invalid_preview}' — sending error to model for self-correction\")\n                         assistant_msg = self._build_assistant_message(assistant_message, finish_reason)\n                         messages.append(assistant_msg)\n-                        self._log_msg_to_db(assistant_msg)\n                         for tc in assistant_message.tool_calls:\n                             if tc.function.name not in self.valid_tool_names:\n                                 content = f\"Tool '{tc.function.name}' does not exist. Available tools: {available}\"","@@ -4169,7 +4139,6 @@ def run_conversation(\n                             )\n                             recovery_dict = {\"role\": \"user\", \"content\": recovery_msg}\n                             messages.append(recovery_dict)\n-                            self._log_msg_to_db(recovery_dict)\n                             continue\n                     \n                     # Reset retry counter on successful JSON validation","@@ -4191,7 +4160,6 @@ def run_conversation(\n                                 print(f\"  ┊ 💬 {clean}\")\n                     \n                     messages.append(assistant_msg)\n-                    self._log_msg_to_db(assistant_msg)\n                     \n                     self._execute_tool_calls(assistant_message, messages, effective_task_id)\n ","@@ -4292,7 +4260,6 @@ def run_conversation(\n                                 \"finish_reason\": finish_reason,\n                             }\n                             messages.append(empty_msg)\n-                            self._log_msg_to_db(empty_msg)\n                             \n                             self._cleanup_task_resources(effective_task_id)\n                             self._persist_session(messages, conversation_history)","@@ -4323,7 +4290,6 @@ def run_conversation(\n                         codex_ack_continuations += 1\n                         interim_msg = self._build_assistant_message(assistant_message, \"incomplete\")\n                         messages.append(interim_msg)\n-                        self._log_msg_to_db(interim_msg)\n \n                         continue_msg = {\n                             \"role\": \"user\",","@@ -4333,7 +4299,6 @@ def run_conversation(\n                             ),\n                         }\n                         messages.append(continue_msg)\n-                        self._log_msg_to_db(continue_msg)\n                         self._session_messages = messages\n                         self._save_session_log(messages)\n                         continue","@@ -4349,7 +4314,6 @@ def run_conversation(\n                     final_msg = self._build_assistant_message(assistant_message, finish_reason)\n                     \n                     messages.append(final_msg)\n-                    self._log_msg_to_db(final_msg)\n                     \n                     if not self.quiet_mode:\n                         print(f\"🎉 Conversation completed after {api_call_count} OpenAI-compatible API call(s)\")","@@ -4386,7 +4350,6 @@ def run_conversation(\n                                     \"content\": f\"Error executing tool: {error_msg}\",\n                                 }\n                                 messages.append(err_msg)\n-                                self._log_msg_to_db(err_msg)\n                         pending_handled = True\n                     break\n                 ","@@ -4399,7 +4362,6 @@ def run_conversation(\n                         \"content\": f\"[System error during processing: {error_msg}]\",\n                     }\n                     messages.append(sys_err_msg)\n-                    self._log_msg_to_db(sys_err_msg)\n                 \n                 # If we're near the limit, break to avoid infinite loops\n                 if api_call_count >= self.max_iterations - 1:"]},{"file":"tests/test_860_dedup.py","hunks":["@@ -0,0 +1,294 @@\n+\"\"\"Tests for issue #860 — SQLite session transcript deduplication.\n+\n+Verifies that:\n+1. _flush_messages_to_session_db uses _last_flushed_db_idx to avoid re-writing\n+2. Multiple _persist_session calls don't duplicate messages\n+3. append_to_transcript(skip_db=True) skips SQLite but writes JSONL\n+4. The gateway doesn't double-write messages the agent already persisted\n+\"\"\"\n+\n+import json\n+import os\n+import sqlite3\n+import tempfile\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+# ---------------------------------------------------------------------------\n+# Test: _flush_messages_to_session_db only writes new messages\n+# ---------------------------------------------------------------------------\n+\n+class TestFlushDeduplication:\n+    \"\"\"Verify _flush_messages_to_session_db tracks what it already wrote.\"\"\"\n+\n+    def _make_agent(self, session_db):\n+        \"\"\"Create a minimal AIAgent with a real session DB.\"\"\"\n+        with patch.dict(os.environ, {\"OPENROUTER_API_KEY\": \"[redacted:secret-assignment]\"}):\n+            from run_agent import AIAgent\n+            agent = AIAgent(\n+                model=\"test/model\",\n+                quiet_mode=True,\n+                session_db=session_db,\n+                session_id=\"test-session-860\",\n+                skip_context_files=True,\n+                skip_memory=True,\n+            )\n+        return agent\n+\n+    def test_flush_writes_only_new_messages(self):\n+        \"\"\"First flush writes all new messages, second flush writes none.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db_path = Path(tmpdir) / \"test.db\"\n+            db = SessionDB(db_path=db_path)\n+\n+            agent = self._make_agent(db)\n+\n+            conversation_history = [\n+                {\"role\": \"user\", \"content\": \"old message\"},\n+            ]\n+            messages = list(conversation_history) + [\n+                {\"role\": \"user\", \"content\": \"new question\"},\n+                {\"role\": \"assistant\", \"content\": \"new answer\"},\n+            ]\n+\n+            # First flush — should write 2 new messages\n+            agent._flush_messages_to_session_db(messages, conversation_history)\n+\n+            rows = db.get_messages(agent.session_id)\n+            assert len(rows) == 2, f\"Expected 2 messages, got {len(rows)}\"\n+\n+            # Second flush with SAME messages — should write 0 new messages\n+            agent._flush_messages_to_session_db(messages, conversation_history)\n+\n+            rows = db.get_messages(agent.session_id)\n+            assert len(rows) == 2, f\"Expected still 2 messages after second flush, got {len(rows)}\"\n+\n+    def test_flush_writes_incrementally(self):\n+        \"\"\"Messages added between flushes are written exactly once.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db_path = Path(tmpdir) / \"test.db\"\n+            db = SessionDB(db_path=db_path)\n+\n+            agent = self._make_agent(db)\n+\n+            conversation_history = []\n+            messages = [\n+                {\"role\": \"user\", \"content\": \"hello\"},\n+            ]\n+\n+            # First flush — 1 message\n+            agent._flush_messages_to_session_db(messages, conversation_history)\n+            rows = db.get_messages(agent.session_id)\n+            assert len(rows) == 1\n+\n+            # Add more messages\n+            messages.append({\"role\": \"assistant\", \"content\": \"hi there\"})\n+            messages.append({\"role\": \"user\", \"content\": \"follow up\"})\n+\n+            # Second flush — should write only 2 new messages\n+            agent._flush_messages_to_session_db(messages, conversation_history)\n+            rows = db.get_messages(agent.session_id)\n+            assert len(rows) == 3, f\"Expected 3 total messages, got {len(rows)}\"\n+\n+    def test_persist_session_multiple_calls_no_duplication(self):\n+        \"\"\"Multiple _persist_session calls don't duplicate DB entries.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db_path = Path(tmpdir) / \"test.db\"\n+            db = SessionDB(db_path=db_path)\n+\n+            agent = self._make_agent(db)\n+            # Stub out _save_session_log to avoid file I/O\n+            agent._save_session_log = MagicMock()\n+\n+            conversation_history = [{\"role\": \"user\", \"content\": \"old\"}]\n+            messages = list(conversation_history) + [\n+                {\"role\": \"user\", \"content\": \"q1\"},\n+                {\"role\": \"assistant\", \"content\": \"a1\"},\n+                {\"role\": \"user\", \"content\": \"q2\"},\n+                {\"role\": \"assistant\", \"content\": \"a2\"},\n+            ]\n+\n+            # Simulate multiple persist calls (like the agent's many exit paths)\n+            for _ in range(5):\n+                agent._persist_session(messages, conversation_history)\n+\n+            rows = db.get_messages(agent.session_id)\n+            assert len(rows) == 4, f\"Expected 4 messages, got {len(rows)} (duplication bug!)\"\n+\n+    def test_flush_reset_after_compression(self):\n+        \"\"\"After compression creates a new session, flush index resets.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            db_path = Path(tmpdir) / \"test.db\"\n+            db = SessionDB(db_path=db_path)\n+\n+            agent = self._make_agent(db)\n+\n+            # Write some messages\n+            messages = [\n+                {\"role\": \"user\", \"content\": \"msg1\"},\n+                {\"role\": \"assistant\", \"content\": \"reply1\"},\n+            ]\n+            agent._flush_messages_to_session_db(messages, [])\n+\n+            old_session = agent.session_id\n+            assert agent._last_flushed_db_idx == 2\n+\n+            # Simulate what _compress_context does: new session, reset idx\n+            agent.session_id = \"compressed-session-new\"\n+            db.create_session(session_id=agent.session_id, source=\"test\")\n+            agent._last_flushed_db_idx = 0\n+\n+            # Now flush compressed messages to new session\n+            compressed_messages = [\n+                {\"role\": \"user\", \"content\": \"summary of conversation\"},\n+            ]\n+            agent._flush_messages_to_session_db(compressed_messages, [])\n+\n+            new_rows = db.get_messages(agent.session_id)\n+            assert len(new_rows) == 1\n+\n+            # Old session should still have its 2 messages\n+            old_rows = db.get_messages(old_session)\n+            assert len(old_rows) == 2\n+\n+\n+# ---------------------------------------------------------------------------\n+# Test: append_to_transcript skip_db parameter\n+# ---------------------------------------------------------------------------\n+\n+class TestAppendToTranscriptSkipDb:\n+    \"\"\"Verify skip_db=True writes JSONL but not SQLite.\"\"\"\n+\n+    @pytest.fixture()\n+    def store(self, tmp_path):\n+        from gateway.config import GatewayConfig\n+        from gateway.session import SessionStore\n+        config = GatewayConfig()\n+        with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+            s = SessionStore(sessions_dir=tmp_path, config=config)\n+        s._db = None  # no SQLite for these JSONL-focused tests\n+        s._loaded = True\n+        return s\n+\n+    def test_skip_db_writes_jsonl_only(self, store, tmp_path):\n+        \"\"\"With skip_db=True, message appears in JSONL but not SQLite.\"\"\"\n+        session_id = \"test-skip-db\"\n+        msg = {\"role\": \"assistant\", \"content\": \"hello world\"}\n+        store.append_to_transcript(session_id, msg, skip_db=True)\n+\n+        # JSONL should have the message\n+        jsonl_path = store.get_transcript_path(session_id)\n+        assert jsonl_path.exists()\n+        with open(jsonl_path) as f:\n+            lines = f.readlines()\n+        assert len(lines) == 1\n+        parsed = json.loads(lines[0])\n+        assert parsed[\"content\"] == \"hello world\"\n+\n+    def test_skip_db_prevents_sqlite_write(self, tmp_path):\n+        \"\"\"With skip_db=True and a real DB, message does NOT appear in SQLite.\"\"\"\n+        from gateway.config import GatewayConfig\n+        from gateway.session import SessionStore\n+        from hermes_state import SessionDB\n+\n+        db_path = tmp_path / \"test_skip.db\"\n+        db = SessionDB(db_path=db_path)\n+\n+        config = GatewayConfig()\n+        with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+            store = SessionStore(sessions_dir=tmp_path, config=config)\n+        store._db = db\n+        store._loaded = True\n+\n+        session_id = \"test-skip-db-real\"\n+        db.create_session(session_id=session_id, source=\"test\")\n+\n+        msg = {\"role\": \"assistant\", \"content\": \"hello world\"}\n+        store.append_to_transcript(session_id, msg, skip_db=True)\n+\n+        # SQLite should NOT have the message\n+        rows = db.get_messages(session_id)\n+        assert len(rows) == 0, f\"Expected 0 DB rows with skip_db=True, got {len(rows)}\"\n+\n+        # But JSONL should have it\n+        jsonl_path = store.get_transcript_path(session_id)\n+        with open(jsonl_path) as f:\n+            lines = f.readlines()\n+        assert len(lines) == 1\n+\n+    def test_default_writes_both(self, tmp_path):\n+        \"\"\"Without skip_db, message appears in both JSONL and SQLite.\"\"\"\n+        from gateway.config import GatewayConfig\n+        from gateway.session import SessionStore\n+        from hermes_state import SessionDB\n+\n+        db_path = tmp_path / \"test_both.db\"\n+        db = SessionDB(db_path=db_path)\n+\n+        config = GatewayConfig()\n+        with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+            store = SessionStore(sessions_dir=tmp_path, config=config)\n+        store._db = db\n+        store._loaded = True\n+\n+        session_id = \"test-default-write\"\n+        db.create_session(session_id=session_id, source=\"test\")\n+\n+        msg = {\"role\": \"user\", \"content\": \"test message\"}\n+        store.append_to_transcript(session_id, msg)\n+\n+        # JSONL should have the message\n+        jsonl_path = store.get_transcript_path(session_id)\n+        with open(jsonl_path) as f:\n+            lines = f.readlines()\n+        assert len(lines) == 1\n+\n+        # SQLite should also have the message\n+        rows = db.get_messages(session_id)\n+        assert len(rows) == 1\n+\n+\n+# ---------------------------------------------------------------------------\n+# Test: _last_flushed_db_idx initialization\n+# ---------------------------------------------------------------------------\n+\n+class TestFlushIdxInit:\n+    \"\"\"Verify _last_flushed_db_idx is properly initialized.\"\"\"\n+\n+    def test_init_zero(self):\n+        \"\"\"Agent starts with _last_flushed_db_idx = 0.\"\"\"\n+        with patch.dict(os.environ, {\"OPENROUTER_API_KEY\": \"[redacted:secret-assignment]\"}):\n+            from run_agent import AIAgent\n+            agent = AIAgent(\n+                model=\"test/model\",\n+                quiet_mode=True,\n+                skip_context_files=True,\n+                skip_memory=True,\n+            )\n+        assert agent._last_flushed_db_idx == 0\n+\n+    def test_no_session_db_noop(self):\n+        \"\"\"Without session_db, flush is a no-op and doesn't crash.\"\"\"\n+        with patch.dict(os.environ, {\"OPENROUTER_API_KEY\": \"[redacted:secret-assignment]\"}):\n+            from run_agent import AIAgent\n+            agent = AIAgent(\n+                model=\"test/model\",\n+                quiet_mode=True,\n+                skip_context_files=True,\n+                skip_memory=True,\n+            )\n+        messages = [{\"role\": \"user\", \"content\": \"test\"}]\n+        agent._flush_messages_to_session_db(messages, [])\n+        # Should not crash, idx should remain 0\n+        assert agent._last_flushed_db_idx == 0"]},{"file":"tests/tools/test_interrupt.py","hunks":["@@ -88,7 +88,7 @@ def test_all_tools_skipped_when_interrupted(self):\n         agent = MagicMock()\n         agent._interrupt_requested = True\n         agent.log_prefix = \"\"\n-        agent._log_msg_to_db = MagicMock()\n+        agent._persist_session = MagicMock()\n \n         # Import and call the method\n         from run_agent import AIAgent\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}