NousResearch/hermes-agent

Telegram: edit messages in place, render todos as checklists

Cluster 30045 · gateway. Data as of

2 open items. Fix merged in #30864 on 23 May 2026. Check it covers your case.

The short answer

1open issue
1open PR
6items grouped
Known?
Yes. 6 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #30864. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #30864. That is separate from the fix event.
Still open
1 issue and 1 PR.

These items focus on updating Telegram gateway messages in place rather than appending new ones, and rendering todo tool results as editable inline checklists.

Open issues1

  • Issue#31001Telegram: render todo tool updates as in-place checklist

Open PRs1

  • PR#31106feat(gateway): render todo tool results as editable Telegram checklist (#31001)

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #30864 feat(telegram): edit status messages in place instead of appending

    merged to main

    files: gateway/platforms/telegram.py, gateway/run.py, tests/gateway/test_telegram_status_update.py

    Show the diff
    • gateway/platforms/telegram.py
      @@ -468,6 +468,10 @@ def __init__(self, config: PlatformConfig):
               # "all"       — every message triggers a push notification (legacy
               #               behavior; opt-in via display.platforms.telegram.notifications).
               self._notifications_mode: str = "important"
      +        # send_or_update_status() bookkeeping: {(chat_id, status_key) -> bot message_id}
      +        # Tracks status bubbles owned by this adapter so subsequent calls with the
      +        # same key edit the same message instead of appending new ones (#30045).
      +        self._status_message_ids: Dict[tuple, str] = {}
       
           def _notification_kwargs(
               self, metadata: Optional[Dict[str, Any]]
      @@ -1908,6 +1912,40 @@ async def send(
                   is_connect_timeout = self._looks_like_connect_timeout(e)
                   return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or not is_timeout))
       
      +    async def send_or_update_status(
      +        self,
      +        chat_id: str,
      +        status_key: str,
      +        content: str,
      +        *,
      +        metadata: Optional[Dict[str, Any]] = None,
      +    ) -> SendResult:
      +        """Send a status message, or edit the previous one with the same key.
      +
      +        Issue #30045: progress/status callbacks (context-pressure, lifecycle,
      +        compression, etc.) used to append a fresh bubble on every call. With
      +        this method, the first call sends and the message id is remembered;
      +        subsequent calls with the same (chat_id, status_key) edit that same
      +        message in place. If the edit fails (message deleted, too old, etc.)
      +        we drop the cached id and send fresh.
      +        """
      +        key = (str(chat_id), str(status_key))
      +        cached_id = self._status_message_ids.get(key)
      +        if cached_id is not None:
      +            result = await self.edit_message(
      +                chat_id, cached_id, content, finalize=True, metadata=metadata,
      +            )
      +            if result.success:
      +                if result.message_id:
      +                    self._status_message_ids[key] = str(result.message_id)
      +                return result
      +            # Edit failed — clear the cached id and fall through to a fresh send.
      +            self._status_message_ids.pop(key, None)
      +        result = await self.send(chat_id, content, metadata=metadata)
      +        if result.success and result.message_id:
      +            self._status_message_ids[key] = str(result.message_id)
      +        return result
      +
           async def edit_message(
               self,
               chat_id: str,
    • gateway/run.py
      @@ -238,6 +238,19 @@ def _prepare_gateway_status_message(platform: Any, event_type: str, message: str
           return text
       
       
      +async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata):
      +    """Route a status message through adapter.send_or_update_status when supported.
      +
      +    Issue #30045: adapters that implement send_or_update_status (currently
      +    Telegram) edit the previous bubble for the same status_key instead of
      +    appending a new one. Adapters without the method fall back to plain send.
      +    """
      +    sender = getattr(adapter, "send_or_update_status", None)
      +    if callable(sender):
      +        return await sender(chat_id, status_key, content, metadata=metadata)
      +    return await adapter.send(chat_id, content, metadata=metadata)
      +
      +
       def _telegramize_command_mentions(text: str, platform: Any) -> str:
           """Rewrite slash-command mentions to Telegram-valid command names.
       
      @@ -16141,11 +16154,7 @@ def _status_callback_sync(event_type: str, message: str) -> None:
                       )
                       return
                   _fut = safe_schedule_threadsafe(
      -                _status_adapter.send(
      -                    _status_chat_id,
      -                    prepared_message,
      -                    metadata=_status_thread_metadata,
      -                ),
      +                _send_or_update_status_coro(_status_adapter, _status_chat_id, event_type, prepared_message, _status_thread_metadata),
                       _loop_for_step,
                       logger=logger,
                       log_message=f"status_callback ({event_type}) scheduling error",
    • tests/gateway/test_telegram_status_update.py
      @@ -0,0 +1,162 @@
      +"""Tests for TelegramAdapter.send_or_update_status (issue #30045).
      +
      +The status-update path must:
      +  1. Send a fresh message on the first call for a (chat_id, status_key) pair.
      +  2. Edit that same message on subsequent calls with the same key.
      +  3. Fall back to sending fresh when the cached message edit fails.
      +  4. Keep distinct keys independent (no cross-talk).
      +"""
      +
      +from __future__ import annotations
      +
      +import sys
      +import types
      +from types import SimpleNamespace
      +from unittest.mock import AsyncMock, MagicMock
      +
      +import pytest
      +
      +from gateway.config import PlatformConfig
      +from gateway.platforms.base import SendResult
      +
      +
      +def _install_fake_telegram(monkeypatch):
      +    """Stub the python-telegram-bot package so TelegramAdapter can be imported."""
      +    fake_telegram = types.ModuleType("telegram")
      +    fake_telegram.Update = SimpleNamespace(ALL_TYPES=())
      +    fake_telegram.Bot = object
      +    fake_telegram.Message = object
      +    fake_telegram.InlineKeyboardButton = object
      +    fake_telegram.InlineKeyboardMarkup = object
      +
      +    fake_error = types.ModuleType("telegram.error")
      +    fake_error.NetworkError = type("NetworkError", (Exception,), {})
      +    fake_error.BadRequest = type("BadRequest", (Exception,), {})
      +    fake_error.TimedOut = type("TimedOut", (Exception,), {})
      +    fake_telegram.error = fake_error
      +
      +    fake_constants = types.ModuleType("telegram.constants")
      +    fake_constants.ParseMode = SimpleNamespace(MARKDOWN_V2="MarkdownV2")
      +    fake_constants.ChatType = SimpleNamespace(
      +        GROUP="group", SUPERGROUP="supergroup",
      +        CHANNEL="channel", PRIVATE="private",
      +    )
      +    fake_telegram.constants = fake_constants
      +
      +    fake_ext = types.ModuleType("telegram.ext")
      +    fake_ext.Application = object
      +    fake_ext.CommandHandler = object
      +    fake_ext.CallbackQueryHandler = object
      +    fake_ext.MessageHandler = object
      +    fake_ext.ContextTypes = SimpleNamespace(DEFAULT_TYPE=object)
      +    fake_ext.filters = object
      +
      +    fake_request = types.ModuleType("telegram.request")
      +    fake_request.HTTPXRequest = object
      +
      +    monkeypatch.setitem(sys.modules, "telegram", fake_telegram)
      +    monkeypatch.setitem(sys.modules, "telegram.error", fake_error)
      +    monkeypatch.setitem(sys.modules, "telegram.constants", fake_constants)
      +    monkeypatch.setitem(sys.modules, "telegram.ext", fake_ext)
      +    monkeypatch.setitem(sys.modules, "telegram.request", fake_request)
      +
      +
      [redacted:email]
      +def adapter(monkeypatch):
      +    _install_fake_telegram(monkeypatch)
      +    from gateway.platforms.telegram import TelegramAdapter
      +
      +    a = TelegramAdapter(PlatformConfig(enabled=True, token="[redacted:secret-assignment]"))
      +    a._bot = MagicMock()
      +    # Patch send / edit_message so tests can drive them directly.
      +    a.send = AsyncMock()
      +    a.edit_message = AsyncMock()
      +    return a
      +
      +
      [redacted:email]
      +async def test_first_call_sends_and_caches_message_id(adapter):
      +    """First call for a (chat, key) pair must send and remember the id."""
      +    adapter.send.return_value = SendResult(success=True, message_id="100")
      +
      +    result = await adapter.send_or_update_status("chat-1", "lifecycle", "starting")
      +
      +    assert result.success is True
      +    assert result.message_id == "100"
      +    adapter.send.assert_awaited_once()
      +    adapter.edit_message.assert_not_awaited()
      +    assert adapter._status_message_ids[("chat-1", "lifecycle")] == "100"
      +
      +
      [redacted:email]
      +async def test_second_call_edits_in_place(adapter):
      +    """Same (chat, key) on the second call must edit, not send."""
      +    adapter.send.return_value = SendResult(success=True, message_id="100")
      +    adapter.edit_message.return_value = SendResult(success=True, message_id="100")
      +
      +    await adapter.send_or_update_status("chat-1", "lifecycle", "step 1")
      +    await adapter.send_or_update_status("chat-1", "lifecycle", "step 2")
      +
      +    adapter.send.assert_awaited_once()
      +    adapter.edit_message.assert_awaited_once()
      +    # Edit was directed at the cached message id.
      +    args, kwargs = adapter.edit_message.call_args
      +    assert args[0] == "chat-1"
      +    assert args[1] == "100"
      +    assert args[2] == "step 2"
      +
      +
      [redacted:email]
      +async def test_edit_failure_falls_back_to_fresh_send(adapter):
      +    """When edit_message fails the cache is cleared and a new send happens."""
      +    adapter.send.side_effect = [
      +        SendResult(success=True, message_id="100"),
      +        SendResult(success=True, message_id="200"),
      +    ]
      +    adapter.edit_message.return_value = SendResult(
      +        success=False, error="Bad Request: message to edit not found",
      +    )
      +
      +    await adapter.send_or_update_status("chat-1", "lifecycle", "step 1")
      +    result = await adapter.send_or_update_status("chat-1", "lifecycle", "step 2")
      +
      +    assert result.success is True
      +    assert result.message_id == "200"
      +    assert adapter.send.await_count == 2
      +    assert adapter.edit_message.await_count == 1
      +    # Cache now points at the fresh message id.
      +    assert adapter._status_message_ids[("chat-1", "lifecycle")] == "200"
      +
      +
      [redacted:email]
      +async def test_distinct_status_keys_do_not_collide(adapter):
      +    """A different status_key gets its own message; the original isn't touched."""
      +    adapter.send.side_effect = [
      +        SendResult(success=True, message_id="100"),
      +        SendResult(success=True, message_id="200"),
      +    ]
      +
      +    await adapter.send_or_update_status("chat-1", "lifecycle", "ctx pressure")
      +    await adapter.send_or_update_status("chat-1", "model-switch", "switched to opus")
      +
      +    assert adapter.send.await_count == 2
      +    adapter.edit_message.assert_not_awaited()
      +    assert adapter._status_message_ids[("chat-1", "lifecycle")] == "100"
      +    assert adapter._status_message_ids[("chat-1", "model-switch")] == "200"
      +
      +
      [redacted:email]
      +async def test_distinct_chat_ids_do_not_collide(adapter):
      +    """Same status_key in different chats must not edit each other's messages."""
      +    adapter.send.side_effect = [
      +        SendResult(success=True, message_id="100"),
      +        SendResult(success=True, message_id="200"),
      +    ]
      +
      +    await adapter.send_or_update_status("chat-1", "lifecycle", "first")
      +    await adapter.send_or_update_status("chat-2", "lifecycle", "second")
      +
      +    assert adapter.send.await_count == 2
      +    adapter.edit_message.assert_not_awaited()
      +    assert adapter._status_message_ids[("chat-1", "lifecycle")] == "100"
      +    assert adapter._status_message_ids[("chat-2", "lifecycle")] == "200"

2 PRs closed without merging

From the close comments: stale or abandoned 1 · other 1

  • #30090 feat(telegram): edit gateway status updates · stale or abandoned
  • #30141 feat(telegram): edit gateway status updates · other → 9acf949, #30864, #30045
Closed issues · 1
  • Closed#30045feat: update Telegram status messages instead of appending progress spam
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#30864
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
23 May 2026

For your agent

The same facts, typed.