{"repo":"NousResearch/hermes-agent","public_id":"49712","display_label":"Telegram typing indicator lingering during stream finalize","summary":"The members concern the Telegram gateway's typing indicator, with one bug report describing it persisting in group chats alongside a roughly 10-second delay in stream finalization. The other two titles describe a change to pause Telegram typing before stream finalize, with one referencing #49712; the titles alone don't confirm outcomes.","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":50378,"merged_at":"2026-06-21T20:10:25+00:00"},"resolution":{"state":"resolved","by":"pr-50378"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T20:10:25+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":49712,"pr_number":50378,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-21T20:10:25+00:00"}],"members":[{"kind":"issue","number":49712,"title":"[Bug]: Telegram group typing indicator persists and stream finalize delayed by ~10s","url":null,"state":"closed"},{"kind":"pr","number":49872,"title":"fix(gateway): pause Telegram typing before stream finalize","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50378","number":50378},{"kind":"pr","value":"#49712","number":49712}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":50378,"title":"fix(gateway): pause Telegram typing before stream finalize (#49712)","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_stream_consumer.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -14133,6 +14133,13 @@ def _run_still_current() -> bool:\n                 from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig\n                 _adapter = self.adapters.get(source.platform)\n                 if _adapter:\n+                    _pause_typing_before_finalize = None\n+                    if source.platform == Platform.TELEGRAM and hasattr(_adapter, \"pause_typing_for_chat\"):\n+                        def _pause_typing_before_finalize(\n+                            _adapter=_adapter,\n+                            _chat_id=source.chat_id,\n+                        ) -> None:\n+                            _adapter.pause_typing_for_chat(_chat_id)\n                     _adapter_supports_edit = getattr(_adapter, \"SUPPORTS_MESSAGE_EDITING\", True)\n                     _effective_cursor = _scfg.cursor if _adapter_supports_edit else \"\"\n                     _buffer_only = False","@@ -14162,6 +14169,7 @@ def _run_still_current() -> bool:\n                         chat_id=source.chat_id,\n                         config=_consumer_cfg,\n                         metadata=_thread_metadata,\n+                        on_before_finalize=_pause_typing_before_finalize,\n                         initial_reply_to_id=event_message_id,\n                     )\n             except Exception as _sc_err:","@@ -15290,6 +15298,13 @@ def run_sync():\n                     from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig\n                     _adapter = self.adapters.get(source.platform)\n                     if _adapter:\n+                        _pause_typing_before_finalize = None\n+                        if source.platform == Platform.TELEGRAM and hasattr(_adapter, \"pause_typing_for_chat\"):\n+                            def _pause_typing_before_finalize(\n+                                _adapter=_adapter,\n+                                _chat_id=source.chat_id,\n+                            ) -> None:\n+                                _adapter.pause_typing_for_chat(_chat_id)\n                         # Platforms that don't support editing sent messages\n                         # (e.g. QQ, WeChat) should skip streaming entirely —\n                         # without edit support, the consumer sends a partial","@@ -15334,6 +15349,7 @@ def run_sync():\n                                 if progress_queue is not None\n                                 else None\n                             ),\n+                            on_before_finalize=_pause_typing_before_finalize,\n                             initial_reply_to_id=event_message_id,\n                         )\n                         if _want_stream_deltas:"]},{"file":"gateway/stream_consumer.py","hunks":["@@ -119,6 +119,7 @@ def __init__(\n         config: Optional[StreamConsumerConfig] = None,\n         metadata: Optional[dict] = None,\n         on_new_message: Optional[callable] = None,\n+        on_before_finalize: Optional[Callable[[], Any]] = None,\n         initial_reply_to_id: Optional[str] = None,\n     ):\n         self.adapter = adapter","@@ -133,6 +134,10 @@ def __init__(\n         # the content, not edit the old bubble above it.\n         # Called with no arguments. Exceptions are swallowed.\n         self._on_new_message = on_new_message\n+        # Fired once when the stream transitions into its finalization path.\n+        # Gateway callers use this to pause typing refreshes before a slow\n+        # final rich-text edit (Telegram MarkdownV2 finalize, etc.).\n+        self._on_before_finalize = on_before_finalize\n         self._initial_reply_to_id = initial_reply_to_id\n         self._queue: queue.Queue = queue.Queue()\n         self._accumulated = \"\"","@@ -196,6 +201,7 @@ def __init__(\n         # first failure we permanently disable drafts for the remainder of\n         # this response and route through edit-based for graceful degradation.\n         self._draft_failures = 0\n+        self._before_finalize_notified = False\n \n     def _metadata_for_send(\n         self,","@@ -242,6 +248,20 @@ def final_content_delivered(self) -> bool:\n         the subsequent cosmetic edit (cursor removal) failed.\"\"\"\n         return self._final_content_delivered\n \n+    async def _notify_before_finalize(self) -> None:\n+        \"\"\"Run the pre-finalize hook exactly once, swallowing hook errors.\"\"\"\n+        if self._before_finalize_notified:\n+            return\n+        self._before_finalize_notified = True\n+        if self._on_before_finalize is None:\n+            return\n+        try:\n+            result = self._on_before_finalize()\n+            if inspect.isawaitable(result):\n+                await result\n+        except Exception:\n+            pass\n+\n     async def _edit_message(\n         self,\n         *,","@@ -620,6 +640,8 @@ async def run(self) -> None:\n                     self._last_edit_time = time.monotonic()\n \n                 if got_done:\n+                    if self._accumulated or self._message_id is not None or self._already_sent:\n+                        await self._notify_before_finalize()\n                     # Final edit without cursor. If progressive editing failed\n                     # mid-stream, send a single continuation/fallback message\n                     # here instead of letting the base gateway path send the"]},{"file":"tests/gateway/test_stream_consumer.py","hunks":["@@ -361,6 +361,67 @@ async def test_stream_with_media_tag(self):\n         assert consumer.already_sent\n \n \n+class TestBeforeFinalizeHook:\n+    \"\"\"Verify the optional pre-finalize hook fires at the right time.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_hook_runs_before_finalize_edit(self):\n+        \"\"\"Adapters that require finalize should pause typing before the edit.\"\"\"\n+        events = []\n+        adapter = MagicMock()\n+        adapter.REQUIRES_EDIT_FINALIZE = True\n+        adapter.send = AsyncMock(\n+            side_effect=lambda **_kw: (\n+                events.append(\"send\"),\n+                SimpleNamespace(success=True, message_id=\"msg_1\"),\n+            )[1]\n+        )\n+        adapter.edit_message = AsyncMock(\n+            side_effect=lambda **_kw: (\n+                events.append(\"edit\"),\n+                SimpleNamespace(success=True, message_id=\"msg_1\"),\n+            )[1]\n+        )\n+        adapter.MAX_MESSAGE_LENGTH = 4096\n+\n+        consumer = GatewayStreamConsumer(\n+            adapter,\n+            \"chat_123\",\n+            StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5),\n+            on_before_finalize=lambda: events.append(\"pause\"),\n+        )\n+        consumer.on_delta(\"Hello\")\n+        consumer.finish()\n+\n+        await consumer.run()\n+\n+        assert events == [\"send\", \"pause\", \"edit\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_hook_runs_once_when_final_text_already_visible(self):\n+        \"\"\"The hook still fires once even when no final edit is required.\"\"\"\n+        events = []\n+        adapter = MagicMock()\n+        adapter.REQUIRES_EDIT_FINALIZE = False\n+        adapter.send = AsyncMock(return_value=SimpleNamespace(success=True, message_id=\"msg_1\"))\n+        adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True, message_id=\"msg_1\"))\n+        adapter.MAX_MESSAGE_LENGTH = 4096\n+\n+        consumer = GatewayStreamConsumer(\n+            adapter,\n+            \"chat_123\",\n+            StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5),\n+            on_before_finalize=lambda: events.append(\"pause\"),\n+        )\n+        consumer.on_delta(\"Hello\")\n+        consumer.finish()\n+\n+        await consumer.run()\n+\n+        assert events == [\"pause\"]\n+        adapter.edit_message.assert_not_called()\n+\n+\n # ── Segment break (tool boundary) tests ──────────────────────────────────\n \n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}