From 565b7c8d9d879c6423c55e9be84596936bc489ba Mon Sep 17 00:00:00 2001 From: natehale Date: Sun, 21 Jun 2026 12:18:28 -0700 Subject: [PATCH] fix(telegram): stop typing indicator lingering after final reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the agent's final response, the '...typing' bubble persisted ~5s. send() re-triggers send_typing() after every delivery so the bubble survives intermediate progress messages (Telegram clears typing on each delivered message). But that re-trigger also fired on the FINAL send, re-arming Telegram's ~5s timer AFTER the gateway had already torn down its typing-refresh loop — and Telegram exposes no stop-typing API, so nothing cancelled it. Gate the post-send re-trigger on the absence of metadata['notify'] (set only on the final user-visible reply via _mark_notify_metadata). Both the rich-message and legacy send paths are covered; intermediate progress sends still re-trigger so the bubble stays alive mid-response. Fixes #48678 --- plugins/platforms/telegram/adapter.py | 30 ++++++++++++++++-------- tests/gateway/test_telegram_format.py | 33 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 1dcad13bb86..91cc4c14903 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -2517,11 +2517,17 @@ class TelegramAdapter(BasePlatformAdapter): rich_result = await self._try_send_rich(chat_id, content, reply_to, metadata) if rich_result is not None: if rich_result.success: - # Re-trigger typing like the legacy success path does. - try: - await self.send_typing(chat_id, metadata=metadata) - except Exception: - pass # Typing failures are non-fatal + # Re-trigger typing like the legacy success path does, + # but ONLY for intermediate sends. On the final reply + # (metadata["notify"]) the gateway has already torn down + # the typing refresh loop; re-arming Telegram's ~5s timer + # here would leave the "...typing" bubble lingering after + # the answer (no Bot API call cancels it). See #48678. + if not (metadata or {}).get("notify"): + try: + await self.send_typing(chat_id, metadata=metadata) + except Exception: + pass # Typing failures are non-fatal return rich_result # Format and split message if needed @@ -2746,10 +2752,16 @@ class TelegramAdapter(BasePlatformAdapter): # so without this the "...typing" bubble disappears mid-response # (especially noticeable when the agent sends intermediate progress # messages like "Checking:" before running tools). - try: - await self.send_typing(chat_id, metadata=metadata) - except Exception: - pass # Typing failures are non-fatal + # Skip this on the FINAL reply (metadata["notify"]): the gateway has + # already cancelled the typing refresh loop by the time the final + # send returns, so re-arming Telegram's ~5s timer here would leave + # the indicator lingering after the answer with nothing to cancel + # it (Telegram exposes no stop-typing API). See #48678. + if not (metadata or {}).get("notify"): + try: + await self.send_typing(chat_id, metadata=metadata) + except Exception: + pass # Typing failures are non-fatal return SendResult( success=True, diff --git a/tests/gateway/test_telegram_format.py b/tests/gateway/test_telegram_format.py index 737ecbf75d6..c096a1198b1 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -213,6 +213,39 @@ async def test_legacy_send_keeps_chunk_indicators_outside_fenced_code_lines(adap assert not re.match(r"^```\s+\(\d+/\d+\)$", line), text +@pytest.mark.asyncio +async def test_final_send_does_not_retrigger_typing(adapter): + """The final reply (metadata['notify']) must NOT re-arm Telegram's typing + timer. The gateway has already torn down the refresh loop by then, so a + re-trigger here would leave the '...typing' bubble lingering after the + answer (Telegram has no stop-typing API). See #48678.""" + adapter._bot = MagicMock() + adapter._bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1)) + adapter._bot.send_chat_action = AsyncMock() + adapter._rich_messages_enabled = False + + result = await adapter.send("12345", "All done.", metadata={"notify": True}) + + assert result.success is True + adapter._bot.send_chat_action.assert_not_called() + + +@pytest.mark.asyncio +async def test_intermediate_send_still_retriggers_typing(adapter): + """Intermediate/progress sends (no notify marker) keep re-triggering typing + so the '...typing' bubble survives across progress messages while the agent + is still working.""" + adapter._bot = MagicMock() + adapter._bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1)) + adapter._bot.send_chat_action = AsyncMock() + adapter._rich_messages_enabled = False + + result = await adapter.send("12345", "Checking:", metadata={"expect_edits": True}) + + assert result.success is True + adapter._bot.send_chat_action.assert_awaited() + + # ========================================================================= # format_message - bold and italic # =========================================================================