{"repo":"NousResearch/hermes-agent","public_id":"14238","display_label":"Gateway session split drops final response","summary":"These items address a bug where the final reply or pending response is lost when a session split occurs at a response boundary. The focus is on ensuring delivery confirmation before suppressing sends in the gateway.","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":54764,"merged_at":"2026-06-29T09:37:12+00:00"},"resolution":{"state":"resolved","by":"pr-54764"},"freshness":{"state":"verified","last_verified_at":"2026-06-29T09:37:12+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":14238,"pr_number":54764,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-29T09:37:12+00:00"}],"members":[{"kind":"issue","number":14238,"title":"Pending response lost when session split occurs at response boundary","url":null,"state":"closed"},{"kind":"pr","number":14391,"title":"fix(gateway): do not drop final reply after session split","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"194bff068","number":0},{"kind":"pr","value":"#14238","number":14238}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":54764,"title":"fix(gateway): confirm final delivery before suppressing send (#14238)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","gateway/stream_consumer.py","tests/gateway/test_run_progress_topics.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -17426,6 +17426,26 @@ async def _notify_long_running():\n \n         _notify_task = asyncio.create_task(_notify_long_running())\n \n+        def _stream_confirmed_final_delivery(\n+            consumer,\n+            final_text: str,\n+            *,\n+            previewed: bool = False,\n+        ) -> bool:\n+            \"\"\"Return True only when the actual final reply reached the user.\"\"\"\n+            if consumer is None:\n+                return False\n+            if getattr(consumer, \"final_response_sent\", False):\n+                return True\n+            if previewed:\n+                has_delivered_text = getattr(consumer, \"has_delivered_text\", None)\n+                if callable(has_delivered_text):\n+                    try:\n+                        return bool(has_delivered_text(final_text))\n+                    except Exception:\n+                        return False\n+            return False\n+\n         try:\n             # Run in thread pool to not block.  Use an *inactivity*-based\n             # timeout instead of a wall-clock limit: the agent can run for","@@ -17777,12 +17797,12 @@ async def _notify_long_running():\n                         except Exception as e:\n                             logger.debug(\"Stream consumer wait before queued message failed: %s\", e)\n                     _previewed = bool(result.get(\"response_previewed\"))\n-                    _already_streamed = bool(\n-                        (_sc and getattr(_sc, \"final_response_sent\", False))\n-                        or _previewed\n-                        or (_sc and getattr(_sc, \"final_content_delivered\", False))\n-                    )\n                     first_response = result.get(\"final_response\", \"\")\n+                    _already_streamed = _stream_confirmed_final_delivery(\n+                        _sc,\n+                        first_response,\n+                        previewed=_previewed,\n+                    )\n                     if first_response and not _already_streamed:\n                         try:\n                             logger.info(","@@ -17953,11 +17973,10 @@ async def _notify_long_running():\n         if isinstance(response, dict) and not response.get(\"failed\"):\n             _final = response.get(\"final_response\") or \"\"\n             _is_empty_sentinel = not _final or _final == \"(empty)\"\n-            _streamed = bool(\n-                _sc and getattr(_sc, \"final_response_sent\", False)\n-            )\n             # response_previewed means the interim_assistant_callback already\n-            # sent the final text via the adapter (non-streaming path).\n+            # saw the final text, but only suppress the normal send if that\n+            # exact final text was delivered. Unrelated commentary/progress\n+            # must not be mistaken for the final response (#14238).\n             _previewed = bool(response.get(\"response_previewed\"))\n             _content_delivered = bool(\n                 _sc and getattr(_sc, \"final_content_delivered\", False)","@@ -17966,7 +17985,18 @@ async def _notify_long_running():\n             # after streaming finished — when the response was transformed, always\n             # send the final version so the appended content reaches the client.\n             _transformed = bool(response.get(\"response_transformed\"))\n-            if not _is_empty_sentinel and not _transformed and (_streamed or _previewed or _content_delivered):\n+            # Only suppress the normal send when the actual final reply reached\n+            # the user: the stream consumer streamed it (final_response_sent /\n+            # final_content_delivered), or the interim preview delivered that\n+            # *exact* final text. Unrelated commentary/progress shown during a\n+            # compression/session split must not be mistaken for the final\n+            # response (#14238).\n+            _streamed = _stream_confirmed_final_delivery(\n+                _sc,\n+                _final,\n+                previewed=_previewed,\n+            )\n+            if not _is_empty_sentinel and not _transformed and (_streamed or _content_delivered):\n                 logger.info(\n                     \"Suppressing normal final send for session %s: final delivery already confirmed (streamed=%s previewed=%s content_delivered=%s).\",\n                     session_key or \"?\","]},{"file":"gateway/stream_consumer.py","hunks":["@@ -175,6 +175,7 @@ def __init__(\n         # streaming, even if the final edit (cursor removal etc.)\n         # subsequently failed.\n         self._final_content_delivered = False\n+        self._delivered_commentary_texts: list[str] = []\n         # Cache adapter lifecycle capability: only platforms that need an\n         # explicit finalize call (e.g. DingTalk AI Cards) force us to make\n         # a redundant final edit.  Everyone else keeps the fast path.","@@ -291,6 +292,16 @@ async def _edit_message(\n                 pass\n         return await self.adapter.edit_message(**kwargs)\n \n+    def has_delivered_text(self, text: str) -> bool:\n+        \"\"\"Return True if *text* was already delivered as visible chat content.\"\"\"\n+        target = self._clean_for_display(text or \"\").strip()\n+        if not target:\n+            return False\n+        visible_prefix = self._visible_prefix().strip()\n+        if visible_prefix == target:\n+            return True\n+        return any(sent.strip() == target for sent in self._delivered_commentary_texts)\n+\n     def on_segment_break(self) -> None:\n         \"\"\"Finalize the current stream segment and start a fresh message.\"\"\"\n         self._queue.put(_NEW_SEGMENT)","@@ -1173,6 +1184,10 @@ async def _send_commentary(self, text: str) -> bool:\n                 # stale tool bubble above it so the next tool starts a\n                 # new bubble below.\n                 self._notify_new_message()\n+                # Record the exact delivered text so run.py can confirm whether\n+                # an interim \"preview\" actually carried the final response, vs.\n+                # unrelated commentary delivered during a session split (#14238).\n+                self._delivered_commentary_texts.append(text)\n             return result.success\n         except Exception as e:\n             logger.error(\"Commentary send error: %s\", e)"]},{"file":"tests/gateway/test_run_progress_topics.py","hunks":["@@ -260,6 +260,7 @@ def _make_runner(adapter):\n     runner._session_db = None\n     runner._running_agents = {}\n     runner._session_run_generation = {}\n+    runner.session_store = SimpleNamespace(_entries={}, _save=lambda: None)\n     runner.hooks = SimpleNamespace(loaded_hooks=False)\n     runner.config = SimpleNamespace(\n         thread_sessions_per_user=False,","@@ -625,6 +626,24 @@ def run_conversation(self, message, conversation_history=None, task_id=None):\n         }\n \n \n+class PreviewedSplitAfterCommentaryAgent:\n+    def __init__(self, **kwargs):\n+        self.interim_assistant_callback = kwargs.get(\"interim_assistant_callback\")\n+        self.session_id = kwargs.get(\"session_id\")\n+        self.tools = []\n+\n+    def run_conversation(self, message, conversation_history=None, task_id=None):\n+        if self.interim_assistant_callback:\n+            self.interim_assistant_callback(\"I'll inspect the repo first.\", already_streamed=False)\n+        self.session_id = f\"{self.session_id}-child\"\n+        return {\n+            \"final_response\": \"Final answer after compression.\",\n+            \"response_previewed\": True,\n+            \"messages\": [],\n+            \"api_calls\": 1,\n+        }\n+\n+\n class StreamingRefineAgent:\n     def __init__(self, **kwargs):\n         self.stream_delta_callback = kwargs.get(\"stream_delta_callback\")","@@ -942,6 +961,21 @@ async def test_run_agent_previewed_final_marks_already_sent(monkeypatch, tmp_pat\n     assert [call[\"content\"] for call in adapter.sent] == [\"You're welcome.\"]\n \n \n[redacted:email]\n+async def test_run_agent_previewed_split_keeps_final_delivery_pending(monkeypatch, tmp_path):\n+    adapter, result = await _run_with_agent(\n+        monkeypatch,\n+        tmp_path,\n+        PreviewedSplitAfterCommentaryAgent,\n+        session_id=\"sess-split\",\n+        config_data={\"display\": {\"interim_assistant_messages\": True}},\n+    )\n+\n+    assert result[\"session_id\"] == \"sess-split-child\"\n+    assert result.get(\"already_sent\") is not True\n+    assert [call[\"content\"] for call in adapter.sent] == [\"I'll inspect the repo first.\"]\n+\n+\n @pytest.mark.asyncio\n async def test_run_agent_matrix_streaming_omits_cursor(monkeypatch, tmp_path):\n     adapter, result = await _run_with_agent(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}