{"repo":"NousResearch/hermes-agent","public_id":"2831","display_label":"Typing indicators linger or get stuck after replies","summary":"Bugs and fixes across multiple platforms (Discord, Slack, Matrix, Telegram, BlueBubbles) where typing indicators persist or get stuck after a response is sent or streamed. Work focuses on explicitly stopping or capping typing indicators before or after final message delivery.","subsystem":"gateway","counts":{"members":56,"open_issues":5,"open_issues_distinct":5,"open_prs":8,"closed_issues":15,"merged_prs":4,"attempt_prs":32},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3003},"resolution":{"state":"partially_resolved","by":"pr-10239,pr-44836,pr-50378"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T20:10:25+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.033142857142857134},"fix_edges":[{"issue_number":2831,"pr_number":3003,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":6016,"pr_number":10239,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":26854,"pr_number":44836,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":44677,"pr_number":44836,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":49712,"pr_number":50378,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":2831,"title":"Discord typing indicator lingers after response due to race between base.py _keep_typing and _typing_tasks","url":null,"state":"closed"},{"kind":"issue","number":6016,"title":"[Bug]: Matrix typing indicator lingers for up to 30 seconds after response is sent","url":null,"state":"closed"},{"kind":"issue","number":8387,"title":"Slack: assistant.threads.setStatus persists after response when MCP tools execute post-reply","url":null,"state":"closed"},{"kind":"issue","number":24117,"title":"Slack Assistant thread can stay stuck in 'is thinking...' after response is sent","url":null,"state":"closed"},{"kind":"issue","number":24971,"title":"fix(gateway): _keep_typing task stuck indefinitely when post-delivery callback hangs","url":null,"state":"closed"},{"kind":"issue","number":26728,"title":"[Bug]: Discord typing indicator can get stuck forever during/after long replies","url":null,"state":"closed"},{"kind":"issue","number":26854,"title":"Discord typing indicator stuck indefinitely due to race condition in _keep_typing cleanup","url":null,"state":"closed"},{"kind":"issue","number":28004,"title":"Telegram typing indicator stuck indefinitely after response (_keep_typing race condition)","url":null,"state":"closed"},{"kind":"issue","number":29175,"title":"[Bug]: Typing indicator lingers ~5s after streamed reply finishes","url":null,"state":"closed"},{"kind":"issue","number":31534,"title":"BlueBubbles: typing indicator lingers ~10s after the agent turn completes (phantom typing — extend #3003 to BlueBubbles)","url":null,"state":"open"},{"kind":"issue","number":41168,"title":"Telegram typing indicator persists after background process notification under flood control","url":null,"state":"closed"},{"kind":"issue","number":42087,"title":"Bug: Ghost typing indicator on Telegram after background process / synth_event delivery","url":null,"state":"open"},{"kind":"issue","number":44677,"title":"Bug: Typing indicator persists after response is sent (Discord)","url":null,"state":"closed"},{"kind":"issue","number":47539,"title":"Telegram typing indicator stuck infinitely (orphaned _keep_typing task)","url":null,"state":"closed"},{"kind":"issue","number":49290,"title":"Discord: typing indicator lingers after response is sent","url":null,"state":"open"},{"kind":"issue","number":49712,"title":"[Bug]: Telegram group typing indicator persists and stream finalize delayed by ~10s","url":null,"state":"closed"},{"kind":"issue","number":50763,"title":"Typing indicator persists after /new — _handle_reset_command skips interrupt_session_activity()","url":null,"state":"closed"},{"kind":"issue","number":50766,"title":"Typing indicator persists after /new — _handle_reset_command skips interrupt_session_activity()","url":null,"state":"open"},{"kind":"issue","number":50991,"title":"Orphaned _keep_typing task persists Telegram typing indicator after agent cache idle-eviction","url":null,"state":"closed"},{"kind":"issue","number":62394,"title":"Teams: typing indicator animates forever after responses complete — suspected leaked _keep_typing task","url":null,"state":"open"},{"kind":"pr","number":2832,"title":"fix(discord): cancel platform-level typing tasks in base.py finally block","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3003","number":3003},{"kind":"pr","value":"#2945","number":2945}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":3003,"title":"fix(discord): stop phantom typing indicator after agent turn completes","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -819,6 +819,16 @@ async def _keep_typing(self, chat_id: str, interval: float = 2.0, metadata=None)\n                 await asyncio.sleep(interval)\n         except asyncio.CancelledError:\n             pass  # Normal cancellation when handler completes\n+        finally:\n+            # Ensure the underlying platform typing loop is stopped.\n+            # _keep_typing may have called send_typing() after an outer\n+            # stop_typing() cleared the task dict, recreating the loop.\n+            # Cancelling _keep_typing alone won't clean that up.\n+            if hasattr(self, \"stop_typing\"):\n+                try:\n+                    await self.stop_typing(chat_id)\n+                except Exception:\n+                    pass\n     \n     async def handle_message(self, event: MessageEvent) -> None:\n         \"\"\"","@@ -1130,6 +1140,13 @@ async def _process_message_background(self, event: MessageEvent, session_key: st\n                 await typing_task\n             except asyncio.CancelledError:\n                 pass\n+            # Also cancel any platform-level persistent typing tasks (e.g. Discord)\n+            # that may have been recreated by _keep_typing after the last stop_typing()\n+            try:\n+                if hasattr(self, \"stop_typing\"):\n+                    await self.stop_typing(event.source.chat_id)\n+            except Exception:\n+                pass\n             # Clean up session tracking\n             if session_key in self._active_sessions:\n                 del self._active_sessions[session_key]\n"]}]},{"kind":"pr","number":6020,"title":"fix(matrix): add stop_typing to clear typing indicator after response","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10239","number":10239},{"kind":"pr","value":"#6016","number":6016}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":8413,"title":"fix(slack): clear assistant thread status after reply","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"70a33708e","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":8414,"title":"fix(slack): clear assistant typing indicator after response delivery","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":8598,"title":"fix(slack): respect reply_in_thread and reply_to_mode for channel replies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17184","number":17184},{"kind":"pr","value":"#16188","number":16188}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":10239,"title":"fix(gateway): fix matrix lingering typing indicator","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/matrix.py","tests/gateway/test_matrix.py"],"fix_hunks":[{"file":"gateway/platforms/matrix.py","hunks":["@@ -729,6 +729,14 @@ async def send_typing(\n             except Exception:\n                 pass\n \n+    async def stop_typing(self, chat_id: str) -> None:\n+        \"\"\"Stop the Matrix typing indicator.\"\"\"\n+        if self._client:\n+            try:\n+                await self._client.set_typing(RoomID(chat_id), timeout=0)\n+            except Exception:\n+                pass\n+\n     async def edit_message(\n         self, chat_id: str, message_id: str, content: str\n     ) -> SendResult:"]},{"file":"tests/gateway/test_matrix.py","hunks":["@@ -335,6 +335,29 @@ def _make_adapter():\n     return adapter\n \n \n+# ---------------------------------------------------------------------------\n+# Typing indicator\n+# ---------------------------------------------------------------------------\n+\n+class TestMatrixTypingIndicator:\n+    def setup_method(self):\n+        self.adapter = _make_adapter()\n+        self.adapter._client = MagicMock()\n+        self.adapter._client.set_typing = AsyncMock()\n+\n+    @pytest.mark.asyncio\n+    async def test_stop_typing_clears_matrix_typing_state(self):\n+        \"\"\"stop_typing() should send typing=false instead of waiting for timeout expiry.\"\"\"\n+        from gateway.platforms.matrix import RoomID\n+\n+        await self.adapter.stop_typing(\"!room:example.org\")\n+\n+        self.adapter._client.set_typing.assert_awaited_once_with(\n+            RoomID(\"!room:example.org\"),\n+            timeout=0,\n+        )\n+\n+\n # ---------------------------------------------------------------------------\n # mxc:// URL conversion\n # ---------------------------------------------------------------------------","@@ -1831,4 +1854,3 @@ async def test_set_presence_no_client(self):\n         assert result is False\n \n \n-\n"]}]},{"kind":"pr","number":12901,"title":"fix(slack): explicitly clear assistant thread status","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"70a33708e","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":21688,"title":"fix(telegram): stop typing refresh before response","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"565b7c8d9","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":24139,"title":"fix(slack): track assistant typing per-thread to unstick concurrent chats","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":25003,"title":"fix(gateway): stop typing indicator before post-delivery callback to prevent indefinite typing","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24983","number":24983}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":26838,"title":"fix(discord): cap typing indicator loop lifetime","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"331cb38e2","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":26857,"title":"fix(discord): single-shot send_typing to kill stuck typing indicator race (closes #26854)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"331cb38e21affee3527dbe5a646ac6d5e25f2996","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":29172,"title":"fix(gateway): stop typing indicator before final response delivery","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"6a30cfca8","number":0},{"kind":"pr","value":"#37556","number":37556},{"kind":"pr","value":"#24971","number":24971},{"kind":"pr","value":"#37556","number":37556},{"kind":"pr","value":"#37556","number":37556}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":30732,"title":"fix(telegram): stop typing indicator from lingering after response delivery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#29595","number":29595}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":31544,"title":"fix(bluebubbles): stop phantom typing indicator after agent turn completes","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":33606,"title":"fix(gateway): stop typing before final delivery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"09a96ba0f6ee68d701bd7c4fc2b2518a83b37c62","number":0},{"kind":"commit","value":"565b7c8d9d879c6423c55e9be84596936bc489ba","number":0},{"kind":"commit","value":"6a30cfca82409cbf20b915c832a9acfb46551fe5","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":33631,"title":"fix(telegram): stop typing after final reply","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#37556","number":37556},{"kind":"pr","value":"#37556","number":37556}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":36156,"title":"fix(gateway): stop typing cleanup from hanging post-delivery callbacks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24983","number":24983}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":38413,"title":"fix: stop BlueBubbles typing before final delivery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31544","number":31544}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":41240,"title":"fix(gateway): cap typing indicator lifetime (#41168)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"565b7c8d9d879c6423c55e9be84596936bc489ba","number":0},{"kind":"pr","value":"#41168","number":41168}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":41258,"title":"fix(telegram): pause notify typing during flood-control sends (#41168)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"5600105478ffde29d7566b45421b100eaa29c4ef","number":0},{"kind":"commit","value":"565b7c8d9d879c6423c55e9be84596936bc489ba","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":41306,"title":"fix(gateway): set stop_event before cancelling typing task (#41168)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":41308,"title":"fix(telegram): don't re-trigger typing indicator after last chunk","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"565b7c8d9d879c6423c55e9be84596936bc489ba","number":0},{"kind":"pr","value":"#41491","number":41491}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42129,"title":"fix(gateway): skip typing indicator for internal/synth events","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":42131,"title":"fix(gateway): suppress ghost typing indicator after final response on Telegram","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"565b7c8d9d879c6423c55e9be84596936bc489ba","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":43691,"title":"fix(gateway): prevent orphaned Discord typing loops","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44836","number":44836}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":44740,"title":"fix: stop Discord typing after replies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"331cb38e2","number":0},{"kind":"pr","value":"#44836","number":44836}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":44836,"title":"fix: stop Discord typing after replies","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","scripts/release.py","tests/gateway/test_keep_typing_timeout.py","tests/gateway/test_run_progress_topics.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -3163,6 +3163,38 @@ async def _keep_typing(\n                     pass\n             self._typing_paused.discard(chat_id)\n \n+    async def _stop_typing_refresh(\n+        self,\n+        chat_id: str,\n+        typing_task: asyncio.Task | None = None,\n+        *,\n+        timeout: float = 0.5,\n+        stop_attempts: int = 2,\n+    ) -> None:\n+        \"\"\"Stop the refresh task and platform typing state as one operation.\"\"\"\n+        self._typing_paused.add(chat_id)\n+        try:\n+            if typing_task is not None and not typing_task.done():\n+                typing_task.cancel()\n+                try:\n+                    await asyncio.wait_for(asyncio.shield(typing_task), timeout=timeout)\n+                except (asyncio.CancelledError, asyncio.TimeoutError):\n+                    # The task is cancelled; don't let a slow adapter-specific\n+                    # cleanup block response delivery or shutdown.\n+                    pass\n+            if not hasattr(self, \"stop_typing\"):\n+                return\n+            attempts = max(1, stop_attempts)\n+            for attempt in range(attempts):\n+                try:\n+                    await self.stop_typing(chat_id)\n+                except Exception:\n+                    pass\n+                if attempt < attempts - 1:\n+                    await asyncio.sleep(0)\n+        finally:\n+            self._typing_paused.discard(chat_id)\n+\n     def pause_typing_for_chat(self, chat_id: str) -> None:\n         \"\"\"Pause typing indicator for a chat (e.g. during approval waits).\n ","@@ -4092,14 +4124,10 @@ def _record_delivery(result):\n         )\n \n         async def _stop_typing_task() -> None:\n-            typing_task.cancel()\n-            try:\n-                await asyncio.wait_for(asyncio.shield(typing_task), timeout=0.5)\n-            except (asyncio.CancelledError, asyncio.TimeoutError):\n-                # Cancellation cleanup must not block adapter shutdown.  The\n-                # typing task is already cancelled; if the parent task is also\n-                # cancelling, let this message-processing task unwind now.\n-                pass\n+            await self._stop_typing_refresh(\n+                event.source.chat_id,\n+                typing_task,\n+            )\n         \n         try:\n             await self._run_processing_hook(\"on_processing_start\", event)","@@ -4486,11 +4514,6 @@ async def _stop_typing_task() -> None:\n             # callbacks may perform platform I/O; a stuck callback must not\n             # leave the typing refresh task running indefinitely.\n             await _stop_typing_task()\n-            try:\n-                if hasattr(self, \"stop_typing\"):\n-                    await self.stop_typing(event.source.chat_id)\n-            except Exception:\n-                pass\n             # Fire any one-shot post-delivery callback registered for this\n             # session (e.g. deferred background-review notifications).\n             #","@@ -4524,13 +4547,14 @@ async def _stop_typing_task() -> None:\n                         )\n                 except (asyncio.TimeoutError, Exception):\n                     pass\n-            # Also cancel any platform-level persistent typing tasks (e.g. Discord)\n-            # that may have been recreated by _keep_typing after the last stop_typing()\n-            try:\n-                if hasattr(self, \"stop_typing\"):\n-                    await self.stop_typing(event.source.chat_id)\n-            except Exception:\n-                pass\n+            # Some adapters keep platform-level typing tasks.  If callback\n+            # work or a late refresh recreated one, make one final bounded stop\n+            # before releasing the session guard.\n+            await self._stop_typing_refresh(\n+                event.source.chat_id,\n+                None,\n+                stop_attempts=1,\n+            )\n             # Final drain/release boundary: force-flush any timer that missed\n             # the in-band drain before deciding whether the guard can clear.\n             await self._flush_text_debounce_now(session_key)"]},{"file":"scripts/release.py","hunks":["@@ -75,6 +75,7 @@\n     \"[redacted:email]\": \"harjothkhara\",\n     \"[redacted:email]\": \"HeLLGURD\",\n     \"[redacted:email]\": \"synapsesx\",\n+    \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"dirtyren\",\n     \"[redacted:email]\": \"kdunn926\",\n     \"[redacted:email]\": \"mvanhorn\","]},{"file":"tests/gateway/test_keep_typing_timeout.py","hunks":["@@ -198,3 +198,39 @@ async def recording_send_typing(chat_id, metadata=None):\n         assert calls == [], (\n             f\"send_typing was called on a paused chat: {calls}\"\n         )\n+\n+    @pytest.mark.asyncio\n+    async def test_stop_typing_refresh_blocks_late_cancel_tick(self, monkeypatch):\n+        \"\"\"Final cleanup must not let a cancelled refresh loop send typing again.\"\"\"\n+        adapter = _StubAdapter()\n+        late_sends = []\n+        stop_calls = []\n+\n+        async def send_typing(chat_id, metadata=None):\n+            late_sends.append(chat_id)\n+\n+        async def stop_typing(chat_id):\n+            stop_calls.append((chat_id, chat_id in adapter._typing_paused))\n+\n+        monkeypatch.setattr(adapter, \"send_typing\", send_typing)\n+        monkeypatch.setattr(adapter, \"stop_typing\", stop_typing)\n+\n+        async def late_refresh_after_cancel():\n+            try:\n+                await asyncio.sleep(10)\n+            except asyncio.CancelledError:\n+                if \"discord-chat\" not in adapter._typing_paused:\n+                    await adapter.send_typing(\"discord-chat\")\n+                raise\n+\n+        task = asyncio.create_task(late_refresh_after_cancel())\n+        await asyncio.sleep(0)\n+\n+        await adapter._stop_typing_refresh(\"discord-chat\", task, timeout=1.0)\n+\n+        assert late_sends == []\n+        assert stop_calls == [\n+            (\"discord-chat\", True),\n+            (\"discord-chat\", True),\n+        ]\n+        assert \"discord-chat\" not in adapter._typing_paused"]},{"file":"tests/gateway/test_run_progress_topics.py","hunks":["@@ -1121,7 +1121,15 @@ async def _stop_typing(chat_id):\n     )\n \n     assert [call[\"content\"] for call in adapter.sent] == [\"done\"]\n-    assert events[:2] == [\"typing-stopped\", \"callback-start\"]\n+    # Invariant: typing must stop before the (hung) post-delivery callback\n+    # starts.  Don't pin the exact stop_typing call count — the shared\n+    # cleanup path may make more than one bounded stop attempt.\n+    assert \"typing-stopped\" in events\n+    assert \"callback-start\" in events\n+    assert events.index(\"typing-stopped\") < events.index(\"callback-start\")\n+    assert events[: events.index(\"callback-start\")] == (\n+        [\"typing-stopped\"] * events.index(\"callback-start\")\n+    )\n     assert any(call[\"metadata\"] == {\"stopped\": True} for call in adapter.typing)\n \n \n"]}]},{"kind":"pr","number":49304,"title":"fix(gateway): stop typing indicator on first stream delta for Discord","url":null,"state":"open","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"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},{"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","implication":"superseded"},"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"]}]},{"kind":"pr","number":50774,"title":"fix(gateway): stop orphaned typing indicator on /new reset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"331cb38e21affee3527dbe5a646ac6d5e25f2996","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":52490,"title":"fix(gateway): stop typing before final delivery","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#29172","number":29172},{"kind":"pr","value":"#25210","number":25210},{"kind":"pr","value":"#43390","number":43390}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":54544,"title":"fix(telegram): override stop_typing to clear stuck typing indicator (Fixes #28004)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"565b7c8d9d879c6423c55e9be84596936bc489ba","number":0},{"kind":"commit","value":"09a96ba0f6ee68d701bd7c4fc2b2518a83b37c62","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":123916,"title":"fix(bluebubbles): stop re-lighting the typing indicator after replies","url":null,"state":"open","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}