{"repo":"NousResearch/hermes-agent","public_id":"30045","display_label":"Telegram: edit messages in place, render todos as checklists","summary":"These items focus on updating Telegram gateway messages in place rather than appending new ones, and rendering todo tool results as editable inline checklists.","subsystem":"gateway","counts":{"members":6,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30864,"merged_at":"2026-05-23T09:42:10+00:00"},"resolution":{"state":"partially_resolved","by":"pr-30864"},"freshness":{"state":"verified","last_verified_at":"2026-05-23T09:42:10+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":30045,"pr_number":30864,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-23T09:42:10+00:00"}],"members":[{"kind":"issue","number":30045,"title":"feat: update Telegram status messages instead of appending progress spam","url":null,"state":"closed"},{"kind":"issue","number":31001,"title":"Telegram: render todo tool updates as in-place checklist","url":null,"state":"open"},{"kind":"pr","number":30090,"title":"feat(telegram): edit gateway status updates","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":30141,"title":"feat(telegram): edit gateway status updates","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9acf949e3498e84e9f997cbe332b885d938918c1","number":0},{"kind":"pr","value":"#30864","number":30864},{"kind":"pr","value":"#30045","number":30045}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":30864,"title":"feat(telegram): edit status messages in place instead of appending","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/telegram.py","gateway/run.py","tests/gateway/test_telegram_status_update.py"],"fix_hunks":[{"file":"gateway/platforms/telegram.py","hunks":["@@ -468,6 +468,10 @@ def __init__(self, config: PlatformConfig):\n         # \"all\"       — every message triggers a push notification (legacy\n         #               behavior; opt-in via display.platforms.telegram.notifications).\n         self._notifications_mode: str = \"important\"\n+        # send_or_update_status() bookkeeping: {(chat_id, status_key) -> bot message_id}\n+        # Tracks status bubbles owned by this adapter so subsequent calls with the\n+        # same key edit the same message instead of appending new ones (#30045).\n+        self._status_message_ids: Dict[tuple, str] = {}\n \n     def _notification_kwargs(\n         self, metadata: Optional[Dict[str, Any]]","@@ -1908,6 +1912,40 @@ async def send(\n             is_connect_timeout = self._looks_like_connect_timeout(e)\n             return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or not is_timeout))\n \n+    async def send_or_update_status(\n+        self,\n+        chat_id: str,\n+        status_key: str,\n+        content: str,\n+        *,\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        \"\"\"Send a status message, or edit the previous one with the same key.\n+\n+        Issue #30045: progress/status callbacks (context-pressure, lifecycle,\n+        compression, etc.) used to append a fresh bubble on every call. With\n+        this method, the first call sends and the message id is remembered;\n+        subsequent calls with the same (chat_id, status_key) edit that same\n+        message in place. If the edit fails (message deleted, too old, etc.)\n+        we drop the cached id and send fresh.\n+        \"\"\"\n+        key = (str(chat_id), str(status_key))\n+        cached_id = self._status_message_ids.get(key)\n+        if cached_id is not None:\n+            result = await self.edit_message(\n+                chat_id, cached_id, content, finalize=True, metadata=metadata,\n+            )\n+            if result.success:\n+                if result.message_id:\n+                    self._status_message_ids[key] = str(result.message_id)\n+                return result\n+            # Edit failed — clear the cached id and fall through to a fresh send.\n+            self._status_message_ids.pop(key, None)\n+        result = await self.send(chat_id, content, metadata=metadata)\n+        if result.success and result.message_id:\n+            self._status_message_ids[key] = str(result.message_id)\n+        return result\n+\n     async def edit_message(\n         self,\n         chat_id: str,"]},{"file":"gateway/run.py","hunks":["@@ -238,6 +238,19 @@ def _prepare_gateway_status_message(platform: Any, event_type: str, message: str\n     return text\n \n \n+async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata):\n+    \"\"\"Route a status message through adapter.send_or_update_status when supported.\n+\n+    Issue #30045: adapters that implement send_or_update_status (currently\n+    Telegram) edit the previous bubble for the same status_key instead of\n+    appending a new one. Adapters without the method fall back to plain send.\n+    \"\"\"\n+    sender = getattr(adapter, \"send_or_update_status\", None)\n+    if callable(sender):\n+        return await sender(chat_id, status_key, content, metadata=metadata)\n+    return await adapter.send(chat_id, content, metadata=metadata)\n+\n+\n def _telegramize_command_mentions(text: str, platform: Any) -> str:\n     \"\"\"Rewrite slash-command mentions to Telegram-valid command names.\n ","@@ -16141,11 +16154,7 @@ def _status_callback_sync(event_type: str, message: str) -> None:\n                 )\n                 return\n             _fut = safe_schedule_threadsafe(\n-                _status_adapter.send(\n-                    _status_chat_id,\n-                    prepared_message,\n-                    metadata=_status_thread_metadata,\n-                ),\n+                _send_or_update_status_coro(_status_adapter, _status_chat_id, event_type, prepared_message, _status_thread_metadata),\n                 _loop_for_step,\n                 logger=logger,\n                 log_message=f\"status_callback ({event_type}) scheduling error\","]},{"file":"tests/gateway/test_telegram_status_update.py","hunks":["@@ -0,0 +1,162 @@\n+\"\"\"Tests for TelegramAdapter.send_or_update_status (issue #30045).\n+\n+The status-update path must:\n+  1. Send a fresh message on the first call for a (chat_id, status_key) pair.\n+  2. Edit that same message on subsequent calls with the same key.\n+  3. Fall back to sending fresh when the cached message edit fails.\n+  4. Keep distinct keys independent (no cross-talk).\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import sys\n+import types\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+from gateway.config import PlatformConfig\n+from gateway.platforms.base import SendResult\n+\n+\n+def _install_fake_telegram(monkeypatch):\n+    \"\"\"Stub the python-telegram-bot package so TelegramAdapter can be imported.\"\"\"\n+    fake_telegram = types.ModuleType(\"telegram\")\n+    fake_telegram.Update = SimpleNamespace(ALL_TYPES=())\n+    fake_telegram.Bot = object\n+    fake_telegram.Message = object\n+    fake_telegram.InlineKeyboardButton = object\n+    fake_telegram.InlineKeyboardMarkup = object\n+\n+    fake_error = types.ModuleType(\"telegram.error\")\n+    fake_error.NetworkError = type(\"NetworkError\", (Exception,), {})\n+    fake_error.BadRequest = type(\"BadRequest\", (Exception,), {})\n+    fake_error.TimedOut = type(\"TimedOut\", (Exception,), {})\n+    fake_telegram.error = fake_error\n+\n+    fake_constants = types.ModuleType(\"telegram.constants\")\n+    fake_constants.ParseMode = SimpleNamespace(MARKDOWN_V2=\"MarkdownV2\")\n+    fake_constants.ChatType = SimpleNamespace(\n+        GROUP=\"group\", SUPERGROUP=\"supergroup\",\n+        CHANNEL=\"channel\", PRIVATE=\"private\",\n+    )\n+    fake_telegram.constants = fake_constants\n+\n+    fake_ext = types.ModuleType(\"telegram.ext\")\n+    fake_ext.Application = object\n+    fake_ext.CommandHandler = object\n+    fake_ext.CallbackQueryHandler = object\n+    fake_ext.MessageHandler = object\n+    fake_ext.ContextTypes = SimpleNamespace(DEFAULT_TYPE=object)\n+    fake_ext.filters = object\n+\n+    fake_request = types.ModuleType(\"telegram.request\")\n+    fake_request.HTTPXRequest = object\n+\n+    monkeypatch.setitem(sys.modules, \"telegram\", fake_telegram)\n+    monkeypatch.setitem(sys.modules, \"telegram.error\", fake_error)\n+    monkeypatch.setitem(sys.modules, \"telegram.constants\", fake_constants)\n+    monkeypatch.setitem(sys.modules, \"telegram.ext\", fake_ext)\n+    monkeypatch.setitem(sys.modules, \"telegram.request\", fake_request)\n+\n+\n[redacted:email]\n+def adapter(monkeypatch):\n+    _install_fake_telegram(monkeypatch)\n+    from gateway.platforms.telegram import TelegramAdapter\n+\n+    a = TelegramAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))\n+    a._bot = MagicMock()\n+    # Patch send / edit_message so tests can drive them directly.\n+    a.send = AsyncMock()\n+    a.edit_message = AsyncMock()\n+    return a\n+\n+\n[redacted:email]\n+async def test_first_call_sends_and_caches_message_id(adapter):\n+    \"\"\"First call for a (chat, key) pair must send and remember the id.\"\"\"\n+    adapter.send.return_value = SendResult(success=True, message_id=\"100\")\n+\n+    result = await adapter.send_or_update_status(\"chat-1\", \"lifecycle\", \"starting\")\n+\n+    assert result.success is True\n+    assert result.message_id == \"100\"\n+    adapter.send.assert_awaited_once()\n+    adapter.edit_message.assert_not_awaited()\n+    assert adapter._status_message_ids[(\"chat-1\", \"lifecycle\")] == \"100\"\n+\n+\n[redacted:email]\n+async def test_second_call_edits_in_place(adapter):\n+    \"\"\"Same (chat, key) on the second call must edit, not send.\"\"\"\n+    adapter.send.return_value = SendResult(success=True, message_id=\"100\")\n+    adapter.edit_message.return_value = SendResult(success=True, message_id=\"100\")\n+\n+    await adapter.send_or_update_status(\"chat-1\", \"lifecycle\", \"step 1\")\n+    await adapter.send_or_update_status(\"chat-1\", \"lifecycle\", \"step 2\")\n+\n+    adapter.send.assert_awaited_once()\n+    adapter.edit_message.assert_awaited_once()\n+    # Edit was directed at the cached message id.\n+    args, kwargs = adapter.edit_message.call_args\n+    assert args[0] == \"chat-1\"\n+    assert args[1] == \"100\"\n+    assert args[2] == \"step 2\"\n+\n+\n[redacted:email]\n+async def test_edit_failure_falls_back_to_fresh_send(adapter):\n+    \"\"\"When edit_message fails the cache is cleared and a new send happens.\"\"\"\n+    adapter.send.side_effect = [\n+        SendResult(success=True, message_id=\"100\"),\n+        SendResult(success=True, message_id=\"200\"),\n+    ]\n+    adapter.edit_message.return_value = SendResult(\n+        success=False, error=\"Bad Request: message to edit not found\",\n+    )\n+\n+    await adapter.send_or_update_status(\"chat-1\", \"lifecycle\", \"step 1\")\n+    result = await adapter.send_or_update_status(\"chat-1\", \"lifecycle\", \"step 2\")\n+\n+    assert result.success is True\n+    assert result.message_id == \"200\"\n+    assert adapter.send.await_count == 2\n+    assert adapter.edit_message.await_count == 1\n+    # Cache now points at the fresh message id.\n+    assert adapter._status_message_ids[(\"chat-1\", \"lifecycle\")] == \"200\"\n+\n+\n[redacted:email]\n+async def test_distinct_status_keys_do_not_collide(adapter):\n+    \"\"\"A different status_key gets its own message; the original isn't touched.\"\"\"\n+    adapter.send.side_effect = [\n+        SendResult(success=True, message_id=\"100\"),\n+        SendResult(success=True, message_id=\"200\"),\n+    ]\n+\n+    await adapter.send_or_update_status(\"chat-1\", \"lifecycle\", \"ctx pressure\")\n+    await adapter.send_or_update_status(\"chat-1\", \"model-switch\", \"switched to opus\")\n+\n+    assert adapter.send.await_count == 2\n+    adapter.edit_message.assert_not_awaited()\n+    assert adapter._status_message_ids[(\"chat-1\", \"lifecycle\")] == \"100\"\n+    assert adapter._status_message_ids[(\"chat-1\", \"model-switch\")] == \"200\"\n+\n+\n[redacted:email]\n+async def test_distinct_chat_ids_do_not_collide(adapter):\n+    \"\"\"Same status_key in different chats must not edit each other's messages.\"\"\"\n+    adapter.send.side_effect = [\n+        SendResult(success=True, message_id=\"100\"),\n+        SendResult(success=True, message_id=\"200\"),\n+    ]\n+\n+    await adapter.send_or_update_status(\"chat-1\", \"lifecycle\", \"first\")\n+    await adapter.send_or_update_status(\"chat-2\", \"lifecycle\", \"second\")\n+\n+    assert adapter.send.await_count == 2\n+    adapter.edit_message.assert_not_awaited()\n+    assert adapter._status_message_ids[(\"chat-1\", \"lifecycle\")] == \"100\"\n+    assert adapter._status_message_ids[(\"chat-2\", \"lifecycle\")] == \"200\"\n"]}]},{"kind":"pr","number":31106,"title":"feat(gateway): render todo tool results as editable Telegram checklist (#31001)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#31001","number":31001}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}