{"repo":"NousResearch/hermes-agent","public_id":"8221","display_label":"Gateway duplicate messages and interrupt race conditions","summary":"These issues and fixes address duplicate user turn persistence, stale response suppression, and race conditions when new messages arrive during or after provider failures in the gateway. The work spans bug reports, deduplication fixes, and related idempotency/cancellation improvements.","subsystem":"gateway","counts":{"members":4,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":10235,"merged_at":"2026-04-15T10:42:25+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-15T10:42:25+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":8221,"pr_number":10235,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-15T10:42:25+00:00"},{"issue_number":8375,"pr_number":10235,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-15T10:42:25+00:00"}],"members":[{"kind":"issue","number":8221,"title":"[Bug]: Telegram duplicate replies when interrupted session sends stale response before processing pending message","url":null,"state":"closed"},{"kind":"issue","number":16108,"title":"[Feature]: Gateway event idempotency, cancellation, and stale-response suppression","url":null,"state":"open"},{"kind":"pr","number":8386,"title":"fix: suppress stale response when session interrupted by new message","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10235","number":10235},{"kind":"pr","value":"#8221","number":8221},{"kind":"pr","value":"#2483","number":2483},{"kind":"pr","value":"#8375","number":8375}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":10235,"title":"fix(gateway): suppress duplicate replies on interrupt and streaming flood control","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/run.py","tests/gateway/test_duplicate_reply_suppression.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1624,6 +1624,21 @@ def _record_delivery(result):\n             # streaming already delivered the text (already_sent=True) or\n             # when the message was queued behind an active agent.  Log at\n             # DEBUG to avoid noisy warnings for expected behavior.\n+            #\n+            # Suppress stale response when the session was interrupted by a\n+            # new message that hasn't been consumed yet.  The pending message\n+            # is processed by the pending-message handler below (#8221/#2483).\n+            if (\n+                response\n+                and interrupt_event.is_set()\n+                and session_key in self._pending_messages\n+            ):\n+                logger.info(\n+                    \"[%s] Suppressing stale response for interrupted session %s\",\n+                    self.name,\n+                    session_key,\n+                )\n+                response = None\n             if not response:\n                 logger.debug(\"[%s] Handler returned empty/None response for %s\", self.name, event.source.chat_id)\n             if response:"]},{"file":"gateway/run.py","hunks":["@@ -9231,15 +9231,11 @@ async def _notify_long_running():\n                                 pass\n                         except Exception as e:\n                             logger.debug(\"Stream consumer wait before queued message failed: %s\", e)\n-                    _response_previewed = bool(result.get(\"response_previewed\"))\n                     _already_streamed = bool(\n                         _sc\n                         and (\n                             getattr(_sc, \"final_response_sent\", False)\n-                            or (\n-                                _response_previewed\n-                                and getattr(_sc, \"already_sent\", False)\n-                            )\n+                            or getattr(_sc, \"already_sent\", False)\n                         )\n                     )\n                     first_response = result.get(\"final_response\", \"\")","@@ -9323,13 +9319,9 @@ async def _notify_long_running():\n         # them even if streaming had sent earlier partial output.\n         _sc = stream_consumer_holder[0]\n         if _sc and isinstance(response, dict) and not response.get(\"failed\"):\n-            _response_previewed = bool(response.get(\"response_previewed\"))\n             if (\n                 getattr(_sc, \"final_response_sent\", False)\n-                or (\n-                    _response_previewed\n-                    and getattr(_sc, \"already_sent\", False)\n-                )\n+                or getattr(_sc, \"already_sent\", False)\n             ):\n                 response[\"already_sent\"] = True\n         "]},{"file":"tests/gateway/test_duplicate_reply_suppression.py","hunks":["@@ -0,0 +1,291 @@\n+\"\"\"Tests for duplicate reply suppression across the gateway stack.\n+\n+Covers three fix paths:\n+  1. base.py: stale response suppressed when interrupt_event is set and a\n+     pending message exists (#8221 / #2483)\n+  2. run.py return path: already_sent propagated from stream consumer's\n+     already_sent flag without requiring response_previewed (#8375)\n+  3. run.py queued-message path: first response correctly detected as\n+     already-streamed when already_sent is True without response_previewed\n+\"\"\"\n+\n+import asyncio\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import Platform, PlatformConfig\n+from gateway.platforms.base import (\n+    BasePlatformAdapter,\n+    MessageEvent,\n+    MessageType,\n+    ProcessingOutcome,\n+    SendResult,\n+)\n+from gateway.session import SessionSource, build_session_key\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+class StubAdapter(BasePlatformAdapter):\n+    \"\"\"Minimal concrete adapter for testing.\"\"\"\n+\n+    def __init__(self):\n+        super().__init__(PlatformConfig(enabled=True, token=\"fake\"), Platform.DISCORD)\n+        self.sent = []\n+\n+    async def connect(self):\n+        return True\n+\n+    async def disconnect(self):\n+        pass\n+\n+    async def send(self, chat_id, content, reply_to=None, metadata=None):\n+        self.sent.append({\"chat_id\": chat_id, \"content\": content})\n+        return SendResult(success=True, message_id=\"msg1\")\n+\n+    async def send_typing(self, chat_id, metadata=None):\n+        pass\n+\n+    async def get_chat_info(self, chat_id):\n+        return {\"id\": chat_id}\n+\n+\n+def _make_event(text=\"hello\", chat_id=\"c1\", user_id=\"u1\"):\n+    return MessageEvent(\n+        text=text,\n+        source=SessionSource(\n+            platform=Platform.DISCORD,\n+            chat_id=chat_id,\n+            chat_type=\"dm\",\n+            user_id=user_id,\n+        ),\n+        message_id=\"m1\",\n+    )\n+\n+\n+# ===================================================================\n+# Test 1: base.py — stale response suppressed on interrupt (#8221)\n+# ===================================================================\n+\n+class TestBaseInterruptSuppression:\n+    @pytest.mark.asyncio\n+    async def test_stale_response_suppressed_when_interrupted(self):\n+        \"\"\"When interrupt_event is set AND a pending message exists,\n+        base.py should suppress the stale response instead of sending it.\"\"\"\n+        adapter = StubAdapter()\n+\n+        stale_response = \"This is the stale answer to the first question.\"\n+        pending_response = \"This is the answer to the second question.\"\n+        call_count = 0\n+\n+        async def fake_handler(event):\n+            nonlocal call_count\n+            call_count += 1\n+            if call_count == 1:\n+                return stale_response\n+            return pending_response\n+\n+        adapter.set_message_handler(fake_handler)\n+\n+        event_a = _make_event(text=\"first question\")\n+        session_key = build_session_key(event_a.source)\n+\n+        # Simulate: message A is being processed, message B arrives\n+        # The interrupt event is set and B is in pending_messages\n+        interrupt_event = asyncio.Event()\n+        interrupt_event.set()\n+        adapter._active_sessions[session_key] = interrupt_event\n+\n+        event_b = _make_event(text=\"second question\")\n+        adapter._pending_messages[session_key] = event_b\n+\n+        await adapter._process_message_background(event_a, session_key)\n+\n+        # The stale response should NOT have been sent.\n+        stale_sends = [s for s in adapter.sent if s[\"content\"] == stale_response]\n+        assert len(stale_sends) == 0, (\n+            f\"Stale response was sent {len(stale_sends)} time(s) — should be suppressed\"\n+        )\n+        # The pending message's response SHOULD have been sent.\n+        pending_sends = [s for s in adapter.sent if s[\"content\"] == pending_response]\n+        assert len(pending_sends) == 1, \"Pending message response should be sent\"\n+\n+    @pytest.mark.asyncio\n+    async def test_response_not_suppressed_without_interrupt(self):\n+        \"\"\"Normal case: no interrupt, response should be sent.\"\"\"\n+        adapter = StubAdapter()\n+\n+        async def fake_handler(event):\n+            return \"Normal response\"\n+\n+        adapter.set_message_handler(fake_handler)\n+        event = _make_event()\n+        session_key = build_session_key(event.source)\n+\n+        await adapter._process_message_background(event, session_key)\n+\n+        assert any(s[\"content\"] == \"Normal response\" for s in adapter.sent)\n+\n+    @pytest.mark.asyncio\n+    async def test_response_not_suppressed_with_interrupt_but_no_pending(self):\n+        \"\"\"Interrupt event set but no pending message (race already resolved) —\n+        response should still be sent.\"\"\"\n+        adapter = StubAdapter()\n+\n+        async def fake_handler(event):\n+            return \"Valid response\"\n+\n+        adapter.set_message_handler(fake_handler)\n+        event = _make_event()\n+        session_key = build_session_key(event.source)\n+\n+        # Set interrupt but no pending message\n+        interrupt_event = asyncio.Event()\n+        interrupt_event.set()\n+        adapter._active_sessions[session_key] = interrupt_event\n+\n+        await adapter._process_message_background(event, session_key)\n+\n+        assert any(s[\"content\"] == \"Valid response\" for s in adapter.sent)\n+\n+\n+# ===================================================================\n+# Test 2: run.py — already_sent without response_previewed (#8375)\n+# ===================================================================\n+\n+class TestAlreadySentWithoutResponsePreviewed:\n+    \"\"\"The already_sent flag on the response dict should be set when the\n+    stream consumer's already_sent is True, even if response_previewed is\n+    False.  This prevents duplicate sends when streaming was interrupted\n+    by flood control.\"\"\"\n+\n+    def _make_mock_stream_consumer(self, already_sent=False, final_response_sent=False):\n+        sc = SimpleNamespace(\n+            already_sent=already_sent,\n+            final_response_sent=final_response_sent,\n+        )\n+        return sc\n+\n+    def test_already_sent_set_without_response_previewed(self):\n+        \"\"\"Stream consumer already_sent=True should propagate to response\n+        dict even when response_previewed is False.\"\"\"\n+        sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=False)\n+        response = {\"final_response\": \"text\", \"response_previewed\": False}\n+\n+        # Reproduce the logic from run.py return path (post-fix)\n+        if sc and isinstance(response, dict) and not response.get(\"failed\"):\n+            if (\n+                getattr(sc, \"final_response_sent\", False)\n+                or getattr(sc, \"already_sent\", False)\n+            ):\n+                response[\"already_sent\"] = True\n+\n+        assert response.get(\"already_sent\") is True\n+\n+    def test_already_sent_not_set_when_nothing_sent(self):\n+        \"\"\"When stream consumer hasn't sent anything, already_sent should\n+        not be set on the response.\"\"\"\n+        sc = self._make_mock_stream_consumer(already_sent=False, final_response_sent=False)\n+        response = {\"final_response\": \"text\", \"response_previewed\": False}\n+\n+        if sc and isinstance(response, dict) and not response.get(\"failed\"):\n+            if (\n+                getattr(sc, \"final_response_sent\", False)\n+                or getattr(sc, \"already_sent\", False)\n+            ):\n+                response[\"already_sent\"] = True\n+\n+        assert \"already_sent\" not in response\n+\n+    def test_already_sent_set_on_final_response_sent(self):\n+        \"\"\"final_response_sent=True should still work as before.\"\"\"\n+        sc = self._make_mock_stream_consumer(already_sent=False, final_response_sent=True)\n+        response = {\"final_response\": \"text\"}\n+\n+        if sc and isinstance(response, dict) and not response.get(\"failed\"):\n+            if (\n+                getattr(sc, \"final_response_sent\", False)\n+                or getattr(sc, \"already_sent\", False)\n+            ):\n+                response[\"already_sent\"] = True\n+\n+        assert response.get(\"already_sent\") is True\n+\n+    def test_already_sent_not_set_on_failed_response(self):\n+        \"\"\"Failed responses should never be suppressed — user needs to see\n+        the error message even if streaming sent earlier partial output.\"\"\"\n+        sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=False)\n+        response = {\"final_response\": \"Error: something broke\", \"failed\": True}\n+\n+        if sc and isinstance(response, dict) and not response.get(\"failed\"):\n+            if (\n+                getattr(sc, \"final_response_sent\", False)\n+                or getattr(sc, \"already_sent\", False)\n+            ):\n+                response[\"already_sent\"] = True\n+\n+        assert \"already_sent\" not in response\n+\n+\n+# ===================================================================\n+# Test 3: run.py queued-message path — _already_streamed detection\n+# ===================================================================\n+\n+class TestQueuedMessageAlreadyStreamed:\n+    \"\"\"The queued-message path should detect that the first response was\n+    already streamed (already_sent=True) even without response_previewed.\"\"\"\n+\n+    def _make_mock_sc(self, already_sent=False, final_response_sent=False):\n+        return SimpleNamespace(\n+            already_sent=already_sent,\n+            final_response_sent=final_response_sent,\n+        )\n+\n+    def test_queued_path_detects_already_streamed(self):\n+        \"\"\"already_sent=True on stream consumer means first response was\n+        streamed — skip re-sending before processing queued message.\"\"\"\n+        _sc = self._make_mock_sc(already_sent=True)\n+\n+        # Reproduce the queued-message logic from run.py (post-fix)\n+        _already_streamed = bool(\n+            _sc\n+            and (\n+                getattr(_sc, \"final_response_sent\", False)\n+                or getattr(_sc, \"already_sent\", False)\n+            )\n+        )\n+\n+        assert _already_streamed is True\n+\n+    def test_queued_path_sends_when_not_streamed(self):\n+        \"\"\"Nothing was streamed — first response should be sent before\n+        processing the queued message.\"\"\"\n+        _sc = self._make_mock_sc(already_sent=False)\n+\n+        _already_streamed = bool(\n+            _sc\n+            and (\n+                getattr(_sc, \"final_response_sent\", False)\n+                or getattr(_sc, \"already_sent\", False)\n+            )\n+        )\n+\n+        assert _already_streamed is False\n+\n+    def test_queued_path_with_no_stream_consumer(self):\n+        \"\"\"No stream consumer at all (streaming disabled) — not streamed.\"\"\"\n+        _sc = None\n+\n+        _already_streamed = bool(\n+            _sc\n+            and (\n+                getattr(_sc, \"final_response_sent\", False)\n+                or getattr(_sc, \"already_sent\", False)\n+            )\n+        )\n+\n+        assert _already_streamed is False\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}