# NousResearch/hermes-agent · Telegram rich message rendering fixes and consistency

## Open now

- Nothing open on GitHub.

Multiple fixes addressing inconsistent rich message rendering in Telegram, including streamed previews, markdown handling, and duplicate message issues. Relates to a reported bug about inconsistent Telegram message display.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #46890. Verify it addresses your case.
- **Recommended:** read PR #46890 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member 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.

## Closed / duplicate issues

- #45911: \[Bug\]: The rendering of messages in Telegram is off, and not all are consistent.

## Prior & competing PRs

- **#46206: fix(telegram): avoid rich final duplicate previews**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `cli-config.yaml.example`, `gateway/config.py`, `gateway/platforms/telegram.py`, `gateway/stream_consumer.py`, `hermes_cli/config.py`, `tests/gateway/test_config.py`, `tests/gateway/test_stream_consumer.py`, `tests/gateway/test_stream_consumer_fresh_final.py`, `tests/gateway/test_telegram_rich_messages.py`, `website/docs/user-guide/configuration.md`, `website/docs/user-guide/messaging/telegram.md`, `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md`, `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md`
- **#46890: fix(telegram): edit streamed previews in place as rich (Bot API 10.1)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `cli-config.yaml.example`, `gateway/platforms/telegram.py`, `hermes_cli/config.py`, `tests/gateway/test_config.py`, `tests/gateway/test_telegram_rich_messages.py`, `website/docs/user-guide/messaging/telegram.md`, `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md`
  - fix hunks:
  - `cli-config.yaml.example`

    ```diff
    @@ -724,7 +724,7 @@ platform_toolsets:
     #     # allowed_chats: ["-1001234567890"]
     #     extra:
     #       disable_link_previews: false  # Set true to suppress Telegram URL previews in bot messages
    -#       rich_messages: false          # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
    +#       rich_messages: false          # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2
     #
     # Discord-specific settings (config.yaml top-level, not under platforms:):
     #
    ```
  - `gateway/platforms/telegram.py`

    ```diff
    @@ -419,11 +419,13 @@ def __init__(self, config: PlatformConfig):
             self._mention_patterns = self._compile_mention_patterns()
             self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
             self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
    -        # Bot API 10.1 Rich Messages: when explicitly enabled, send final
    -        # replies via sendRichMessage with the raw agent markdown so
    -        # tables/task lists/etc. render natively. Disabled by default because
    -        # several Telegram clients accept but render rich messages poorly.
    -        self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
    +        # Bot API 10.1 Rich Messages: render constructs the legacy MarkdownV2
    +        # path degrades (tables → bullet lists, task lists, <details>, block
    +        # math) via sendRichMessage / editMessageText's rich_message param using
    +        # the raw agent markdown. Enabled by default; users can opt out for
    +        # clients that accept but render rich messages poorly via
    +        # platforms.telegram.extra.rich_messages: false.
    +        self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True)
             # Latched off after a capability failure on sendRichMessage /
             # sendRichMessageDraft (e.g. older python-telegram-bot without the
             # endpoint) so later sends skip the doomed rich attempt entirely.
    ```

    ```diff
    @@ -979,31 +981,68 @@ def _has_telegram_desktop_details_math_crash_shape(self, content: str) -> bool:
                     return True
             return False
     
    -    def _should_attempt_rich(
    -        self, content: str, metadata: Optional[Dict[str, Any]] = None
    -    ) -> bool:
    +    def _needs_rich_rendering(self, content: str) -> bool:
    +        """Return True for markdown constructs that the legacy path degrades.
    +
    +        Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram
    +        clients render a consistent font weight/spacing. The rich endpoint is
    +        reserved for constructs where raw markdown materially improves output:
    +        pipe tables (MarkdownV2 has no table syntax and rewrites them into
    +        bullet lists), GFM task lists, collapsible ``<details>`` blocks, and
    +        block math.  Adapted from #45995 (@YonganZhang).
    +        """
    +        if not content:
    +            return False
    +        if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()):
    +            return True
    +        if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content):
    +            return True
    +        if re.search(r"(?m)^<details\b|^</details>|^<summary\b|^</summary>", content):
    +            return True
    +        if "$$" in content:
    +            return True
    +        return False
    +
    +    def _rich_eligible(self, content: str) -> bool:
    +        """Capability/content eligibility for rich, ignoring ``expect_edits``.
    +
    +        Shared core of :meth:`_should_attempt_rich` minus the per-call
    +        ``expect_edits`` metadata gate.  The rich EDIT-finalize path
    +        (:meth:`_try_edit_rich`) needs this: a streamed preview is sent with
    +        ``expect_edits=True`` to stay on the editable path mid-stream, but the
    +        FINAL edit should still upgrade to rich when the content warrants it.
    +        """
             return bool(
    -            getattr(self, "_rich_messages_enabled", False)
    +            getattr(self, "_rich_messages_enabled", True)
                 and not getattr(self, "_rich_send_disabled", False)
    -            and not (metadata or {}).get("expect_edits")
                 and content
                 and content.strip()
    +            and self._needs_rich_rendering(content)
                 and not self._has_telegram_desktop_details_math_crash_shape(content)
                 and self._content_fits_rich_limits(content)
                 and self._bot_supports_rich()
             )
     
    +    def _should_attempt_rich(
    +        self, content: str, metadata: Optional[Dict[str, Any]] = None
    +    ) -> bool:
    +        return bool(
    +            not (metadata or {}).get("expect_edits")
    +            and self._rich_eligible(content)
    +        )
    +
         def prefers_fresh_final_streaming(
             self, content: str, metadata: Optional[Dict[str, Any]] = None
         ) -> bool:
             """Whether to replace a streamed preview with a fresh rich final.
     
    -        Keep this disabled for Telegram. The fresh-final path briefly shows two
    -        copies of the final answer, then deletes the streaming preview after the
    -        rich send succeeds. That is especially visible on clients that support
    -        rich messages well, and it looks like duplicate delivery at the end of
    -        every streamed turn. Until Telegram rich edits are wired directly, final
    -        streamed replies should edit the existing preview in place.
    +        Disabled for Telegram. The fresh-final path briefly shows two copies of
    +        the final answer, then deletes the streaming preview after the rich send
    +        succeeds — it looks like duplicate delivery at the end of every streamed
    +        turn (the reason #46206 reverted it).  Rich finalize is instead handled
    +        by editing the existing preview in place via Bot API 10.1's
    +        ``editMessageText`` ``rich_message`` parameter (see
    +        :meth:`_try_edit_rich`), so no fresh re-send / delete is needed.
             """
             return False
     
    ```

    ```diff
    @@ -1019,7 +1058,7 @@ def streaming_overflow_limit(self) -> Optional[int]:
             streams split exactly as before.
             """
             if (
    -            getattr(self, "_rich_messages_enabled", False)
    +            getattr(self, "_rich_messages_enabled", True)
                 and not getattr(self, "_rich_send_disabled", False)
                 and self._bot_supports_rich()
             ):
    ```

    ```diff
    @@ -1207,9 +1246,74 @@ async def _try_send_rich(
                 message_id=str(message_id) if message_id is not None else None,
             )
     
    +    async def _try_edit_rich(
    +        self,
    +        chat_id: str,
    +        message_id: str,
    +        content: str,
    +    ) -> Optional[SendResult]:
    +        """Edit an existing message in place as a rich message (Bot API 10.1).
    +
    +        Uses ``editMessageText`` with the ``rich_message`` parameter so a
    +        streamed preview can finalize as rich (tables/task lists/details/math)
    +        WITHOUT a fresh send + delete — no duplicate preview.  Mirrors
    +        :meth:`_try_send_rich`'s error contract:
    +
    +        - success → ``SendResult(success=True, message_id=...)``
    +        - permanent / capability error → ``None`` (caller falls back to the
    +          legacy MarkdownV2 edit; capability errors latch rich off)
    +        - transient / unknown → ``SendResult(success=False)`` with retry
    +          semantics (the message may already be edited; do NOT legacy-resend)
    +        """
    +        payload: Dict[str, Any] = {
    +            "chat_id": int(chat_id),
    +            "message_id": int(message_id),
    +            "rich_message": self._rich_message_payload(content),
    +        }
    +        if getattr(self, "_disable_link_previews", False):
    +            payload["link_preview_options"] = {"is_disabled": True}
    +        try:
    +            # Raw Bot API result; do not request return_type=Message (PTB does
    +            # not fully model the 10.1 response shape yet — a post-edit parse
    +            # error must not be mistaken for a failed edit).
    +            await self._bot.do_api_request("editMessageText", api_kwargs=payload)
    +        except Exception as exc:
    +            if self._is_rich_fallback_error(exc):
    +                if self._is_rich_capability_error(exc):
    +                    self._rich_send_disabled = True
    +                # "Message is not modified" — content identical to the current
    +                # rich message; treat as a successful no-op so the caller does
    +                # not fall through to a redundant legacy edit.
    +                if "not modified" in str(exc).lower():
    +                    return SendResult(success=True, message_id=message_id)
    +                logger.debug(
    +                    "[%s] rich editMessageText rejected (%s) — falling back to MarkdownV2 edit",
    +                    self.name, exc,
    +                )
    +                return None
    +            if "not modified" in str(exc).lower():
    +                return SendResult(success=True, message_id=message_id)
    +            err_str = str(exc).lower()
    +            try:
    +                from telegram.error import TimedOut as _TimedOut
    +            except (ImportError, AttributeError):
    +                _TimedOut = None
    +            is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
    +            is_connect_timeout = self._looks_like_connect_timeout(exc)
    +            logger.warning(
    +                "[%s] rich editMessageText transient failure (no legacy resend): %s",
    +                self.name, exc,
    +            )
    +            return SendResult(
    +                success=False,
    +                error=str(exc),
    +                retryable=(is_connect_timeout or not is_timeout),
    +            )
    +        return SendResult(success=True, message_id=message_id)
    +
         def _should_attempt_rich_draft(self, content: str) -> bool:
             return bool(
    -            getattr(self, "_rich_messages_enabled", False)
    +            getattr(self, "_rich_messages_enabled", True)
                 and not getattr(self, "_rich_send_disabled", False)
                 and not getattr(self, "_rich_draft_disabled", False)
                 and content
    ```

    ```diff
    @@ -2555,6 +2659,21 @@ async def edit_message(
             if not self._bot:
                 return SendResult(success=False, error="Not connected")
     
    +        # Rich finalize (Bot API 10.1): when the completed content has
    +        # constructs the legacy MarkdownV2 edit degrades (tables → bullet
    +        # lists, task lists, <details>, block math) and rich is available,
    +        # edit the preview IN PLACE via editMessageText's rich_message param.
    +        # No fresh send + delete → no duplicate preview (the problem #46206
    +        # reverted the fresh-final path for).  Attempted before the 4,096
    +        # overflow pre-flight because the rich text cap is 32,768 — a rich
    +        # table that exceeds the MarkdownV2 limit must not be split into legacy
    +        # chunks.  Falls back to the legacy edit path (overflow split included)
    +        # on capability/permanent rejection.
    +        if finalize and self._rich_eligible(content):
    +            rich_result = await self._try_edit_rich(chat_id, message_id, content)
    +            if rich_result is not None:
    +                return rich_result
    +
             # Pre-flight: if content already exceeds the limit, split-and-deliver
             # without round-tripping a doomed edit.
             if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -1991,7 +1991,7 @@ def _ensure_hermes_home_managed(home: Path):
             "channel_prompts": {},         # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
             "allowed_chats": "",           # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
             "extra": {
    -            "rich_messages": False,     # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
    +            "rich_messages": True,      # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2
             },
         },
     
    ```
  - `tests/gateway/test_config.py`

    ```diff
    @@ -832,7 +832,7 @@ def test_loads_telegram_rich_messages_from_gateway_platform_extra(self, tmp_path
     
             assert config.platforms[Platform.TELEGRAM].extra["rich_messages"] is False
     
    -    def test_load_config_default_disables_telegram_rich_messages(self, tmp_path, monkeypatch):
    +    def test_load_config_default_enables_telegram_rich_messages(self, tmp_path, monkeypatch):
             hermes_home = tmp_path / ".hermes"
             hermes_home.mkdir()
     
    ```

    ```diff
    @@ -842,7 +842,7 @@ def test_load_config_default_disables_telegram_rich_messages(self, tmp_path, mon
     
             config = load_config()
     
    -        assert config["telegram"]["extra"]["rich_messages"] is False
    +        assert config["telegram"]["extra"]["rich_messages"] is True
     
         def test_bridges_telegram_extra_base_url_from_config_yaml(self, tmp_path, monkeypatch):
             hermes_home = tmp_path / ".hermes"
    ```
  - `tests/gateway/test_telegram_rich_messages.py`

    ```diff
    @@ -61,6 +61,8 @@ def _make_adapter(extra=None):
         bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
         bot.send_chat_action = AsyncMock()  # keeps the post-send typing re-trigger quiet
         bot.send_message_draft = AsyncMock(return_value=True)  # legacy draft fallback
    +    bot.edit_message_text = AsyncMock(return_value=MagicMock(message_id=1))  # legacy edit path
    +    bot.delete_message = AsyncMock(return_value=True)
         adapter._bot = bot
         return adapter
     
    ```

    ```diff
    @@ -184,7 +186,10 @@ async def test_rich_messages_opt_out_accepts_string_false():
     
     
     @pytest.mark.asyncio
    -async def test_rich_messages_default_is_disabled():
    +async def test_rich_messages_default_is_enabled():
    +    """Rich messages are on by default (Bot API 10.1); rich-eligible content
    +    (tables/task lists/details/math) goes through sendRichMessage without the
    +    user having to opt in."""
         config = PlatformConfig(enabled=True, token="[redacted:secret-assignment]")
         adapter = TelegramAdapter(config)
         bot = MagicMock()
    ```

    ```diff
    @@ -195,6 +200,42 @@ async def test_rich_messages_default_is_disabled():
     
         result = await adapter.send("12345", RICH_CONTENT)
     
    +    assert result.success is True
    +    bot = adapter._bot
    +    assert bot is not None
    +    bot.do_api_request.assert_awaited_once()
    +    bot.send_message.assert_not_called()
    +
    +
    [redacted:email]
    +async def test_rich_messages_can_be_opted_out():
    +    """Setting platforms.telegram.extra.rich_messages: false keeps every reply
    +    on the legacy MarkdownV2 path even for rich-eligible content."""
    +    config = PlatformConfig(
    +        enabled=True, token="[redacted:secret-assignment]", extra={"rich_messages": False}
    +    )
    +    adapter = TelegramAdapter(config)
    +    bot = MagicMock()
    +    bot.do_api_request = AsyncMock(return_value=SimpleNamespace(message_id=123))
    +    bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
    +    bot.send_chat_action = AsyncMock()
    +    adapter._bot = bot
    +
    +    result = await adapter.send("12345", RICH_CONTENT)
    +
    +    assert result.success is True
    +    bot.do_api_request.assert_not_called()
    +    bot.send_message.assert_awaited()
    +
    +
    [redacted:email]
    +async def test_plain_markdown_stays_on_legacy_path():
    +    """Ordinary replies (no table/task-list/details/math) stay on the legacy
    +    MarkdownV2 path for consistent client rendering, even with rich enabled."""
    +    adapter = _make_adapter()
    +
    +    result = await adapter.send("12345", "Hello **there**\n\nA normal reply.")
    +
         assert result.success is True
         bot = adapter._bot
         assert bot is not None
    ```

    ```diff
    @@ -240,7 +281,9 @@ async def test_oversized_content_skips_rich_and_chunks():
     async def test_rich_limit_is_characters_not_bytes():
         """Telegram's rich limit is UTF-8 characters, not encoded bytes."""
         adapter = _make_adapter()
    -    cjk = "测" * 20000  # 20k chars, 60k UTF-8 bytes
    +    # Rich-eligible (table) so the content takes the rich path; the CJK body
    +    # is 20k chars / 60k UTF-8 bytes — over the byte count, under the char cap.
    +    cjk = "| a | b |\n|---|---|\n" + "测" * 20000  # 20k chars, ~60k UTF-8 bytes
         assert len(cjk.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES
         assert len(cjk) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS
     
    ```

    ```diff
    @@ -324,7 +367,9 @@ async def test_real_ptb_endpoint_missing_falls_back_and_latches_off(exc):
     async def test_rich_payload_preserves_link_preview_disable():
         adapter = _make_adapter(extra={"disable_link_previews": True})
     
    -    result = await adapter.send("12345", "See https://example.com")
    +    result = await adapter.send(
    +        "12345", "| Link | Note |\n|---|---|\n| See https://example.com | x |"
    +    )
     
         assert result.success is True
         api_kwargs = _rich_api_kwargs(adapter)
    ```

    ```diff
    @@ -575,3 +620,139 @@ async def test_rich_draft_opt_out_uses_legacy():
         assert bot is not None
         bot.do_api_request.assert_not_called()
         bot.send_message_draft.assert_awaited_once()
    +
    +
    +# ----------------------------------------------------------------------------
    +# Rich finalize via editMessageText (Bot API 10.1 rich_message edit param).
    +# Streamed previews finalize by editing the existing message IN PLACE as rich,
    +# so tables/task lists survive without a fresh send + delete (no duplicate).
    +# ----------------------------------------------------------------------------
    +
    +
    +def _rich_edit_kwargs(adapter):
    +    """Return the api_kwargs dict from the single editMessageText rich call."""
    +    call = adapter._bot.do_api_request.call_args
    +    assert call.args[0] == "editMessageText"
    +    return call.kwargs["api_kwargs"]
    +
    +
    [redacted:email]
    +async def test_finalize_edit_uses_rich_for_table_content():
    +    """Finalizing a streamed preview whose content is a table edits the
    +    existing message IN PLACE via editMessageText's rich_message param —
    +    no fresh send, no delete, no duplicate."""
    +    adapter = _make_adapter()
    +
    +    result = await adapter.edit_message(
    +        "12345", "555", RICH_CONTENT, finalize=True,
    +    )
    +
    +    assert result.success is True
    +    assert result.message_id == "555"  # same message, edited in place
    +    api_kwargs = _rich_edit_kwargs(adapter)
    +    assert api_kwargs["message_id"] == 555
    +    # RAW markdown is passed through so table pipes survive.
    +    assert api_kwargs["rich_message"]["markdown"] == RICH_CONTENT
    +    # No fresh send / delete — the whole point of the in-place rich edit.
    +    adapter._bot.edit_message_text.assert_not_called()
    +    adapter._bot.delete_message.assert_not_called()
    +
    +
    [redacted:email]
    +async def test_finalize_edit_plain_content_stays_legacy():
    +    """Finalizing plain content (no table/task-list/details/math) uses the
    +    legacy MarkdownV2 edit_message_text path, not the rich edit endpoint."""
    +    adapter = _make_adapter()
    +
    +    result = await adapter.edit_message(
    +        "12345", "555", "Just a normal answer, no rich constructs.", finalize=True,
    +    )
    +
    +    assert result.success is True
    +    adapter._bot.do_api_request.assert_not_called()
    +    adapter._bot.edit_message_text.assert_awaited()
    +
    +
    [redacted:email]
    +async def test_finalize_edit_rich_capability_error_falls_back_to_legacy():
    +    """A capability error on the rich edit latches rich off and falls back to
    +    the legacy MarkdownV2 edit so the user still gets the final answer."""
    +    adapter = _make_adapter()
    +    adapter._bot.do_api_request = AsyncMock(side_effect=PTB_ENDPOINT_NOT_FOUND)
    +
    +    result = await adapter.edit_message(
    +        "12345", "555", RICH_CONTENT, finalize=True,
    +    )
    +
    +    assert result.success is True
    +    assert adapter._rich_send_disabled is True
    +    adapter._bot.edit_message_text.assert_awaited()
    +
    +
    [redacted:email]
    +async def test_finalize_edit_rich_not_modified_is_success_noop():
    +    """'Message is not modified' on a rich edit is a no-op success — must NOT
    +    fall through to a redundant legacy edit."""
    +    adapter = _make_adapter()
    +    adapter._bot.do_api_request = AsyncMock(
    +        side_effect=BadRequest("Message is not modified")
    +    )
    +
    +    result = await adapter.edit_message(
    +        "12345", "555", RICH_CONTENT, finalize=True,
    +    )
    +
    +    assert result.success is True
    +    adapter._bot.edit_message_text.assert_not_called()
    +
    +
    [redacted:email]
    +async def test_non_finalize_edit_never_uses_rich():
    +    """Intermediate (non-finalize) stream edits stay on the plain edit path;
    +    rich is only applied on the final edit."""
    +    adapter = _make_adapter()
    +
    +    result = await adapter.edit_message(
    +        "12345", "555", RICH_CONTENT, finalize=False,
    +    )
    +
    +    assert result.success is True
    +    adapter._bot.do_api_request.assert_not_called()
    +    adapter._bot.edit_message_text.assert_awaited()
    +
    +
    [redacted:email]
    +async def test_finalize_edit_opt_out_uses_legacy():
    +    """With rich_messages: false, even a table finalizes via the legacy
    +    MarkdownV2 edit path."""
    +    adapter = _make_adapter(extra={"rich_messages": False})
    +
    +    result = await adapter.edit_message(
    +        "12345", "555", RICH_CONTENT, finalize=True,
    +    )
    +
    +    assert result.success is True
    +    adapter._bot.do_api_request.assert_not_called()
    +    adapter._bot.edit_message_text.assert_awaited()
    +
    +
    [redacted:email]
    +async def test_finalize_edit_rich_over_markdownv2_limit_not_split():
    +    """A rich table that exceeds the 4,096 MarkdownV2 limit but fits the 32,768
    +    rich cap is edited in place as one rich message, NOT split into legacy
    +    chunks."""
    +    adapter = _make_adapter()
    +    big_table = "| a | b |\n|---|---|\n" + "\n".join(
    +        f"| {'x' * 50} | {'y' * 50} |" for _ in range(40)
    +    )
    +    assert len(big_table) > TelegramAdapter.MAX_MESSAGE_LENGTH
    +    assert len(big_table) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS
    +
    +    result = await adapter.edit_message(
    +        "12345", "555", big_table, finalize=True,
    +    )
    +
    +    assert result.success is True
    +    api_kwargs = _rich_edit_kwargs(adapter)
    +    assert api_kwargs["rich_message"]["markdown"] == big_table
    +    adapter._bot.edit_message_text.assert_not_called()
    ```
  - `website/docs/user-guide/messaging/telegram.md`

    ```diff
    @@ -900,23 +900,23 @@ gateway:
     
     ## Rendering: Rich Messages, Tables and Link Previews
     
    -**Rich Messages (Bot API 10.1).** When opted in, final replies are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so tables, task lists, headings, nested blockquotes, collapsible `<details>`, footnotes/references, math/formulas, underline, sub/superscript, marked text, and anchors render natively — no client-side flattening. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message.
    +**Rich Messages (Bot API 10.1).** Final replies that contain constructs the legacy MarkdownV2 path degrades — tables, task lists, collapsible `<details>`, and block math — are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so they render natively with no client-side flattening. During streaming, the final answer is delivered by **editing the existing preview in place** via `editMessageText`'s `rich_message` parameter — no second message, no delete, so there is no duplicate-delivery flicker at the end of a turn. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message. Ordinary replies (plain prose, bold/italic, simple lists) stay on the MarkdownV2 path for consistent font weight and spacing across clients.
     
    -The rich path is skipped automatically when content exceeds the 32,768-byte rich text limit, and any rejection from Telegram (unsupported endpoint on an older `python-telegram-bot`, parser error, oversized blocks/columns) **transparently falls back** to the MarkdownV2 path — your message is never lost. Transient/network errors are *not* silently re-sent (no duplicate final message).
    +The rich path is skipped automatically when content exceeds the 32,768-character rich text limit, and any rejection from Telegram (unsupported endpoint on an older `python-telegram-bot`, parser error, oversized blocks/columns) **transparently falls back** to the MarkdownV2 path — your message is never lost. Transient/network errors are *not* silently re-sent (no duplicate final message).
     
     **MarkdownV2 fallback.** When the rich path is unavailable for a message, Hermes converts markdown to MarkdownV2. Since MarkdownV2 has no native table syntax, pipe tables are normalized:
     
     - **Small tables** are flattened into **row-group bullets** — each row becomes a readable bulleted list under the column headings. Good for 2–4 columns and short cells.
     - **Larger or wider tables** fall back to a **fenced code block** with aligned columns so nothing collapses.
     
    -Rich messages are disabled by default because some Telegram clients accept the Bot API payload but render it poorly. To opt in for clients that handle rich messages well:
    +Rich messages are **enabled by default**. Some Telegram clients accept the Bot API payload but render it poorly; to opt out and force every reply onto the legacy MarkdownV2 path:
     
     ```yaml
     gateway:
       platforms:
         telegram:
           extra:
    -        rich_messages: true
    +        rich_messages: false
     ```
     
     This setting is for client-rendering compatibility; Hermes already falls back automatically when Telegram rejects the rich API call. If you only want the legacy "always code-block" table behavior while keeping rich messages enabled, disable table normalization by setting `telegram.pretty_tables: false` in `config.yaml` (default: `true`).
    ```
  - `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md`

    ```diff
    @@ -877,23 +877,23 @@ gateway:
     
     ## 渲染：富消息、表格和链接预览
     
    -**富消息（Bot API 10.1）。** 选择启用后，最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送，使用 Agent 的**原始 markdown**，因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中，实时流式预览也使用 `sendRichMessageDraft`，因此动画草稿与最终的富消息保持一致。
    +**富消息（Bot API 10.1）。** 最终回复中那些会被旧版 MarkdownV2 路径降级的结构——表格、任务列表、可折叠的 `<details>` 以及块级数学公式——会通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送，使用 Agent 的**原始 markdown**，从而原生渲染、无需客户端展平。在流式传输过程中，最终答案通过 `editMessageText` 的 `rich_message` 参数**就地编辑现有预览**来交付——不发第二条消息、不删除，因此一轮结束时不会出现重复投递的闪烁。在私聊中，实时流式预览也使用 `sendRichMessageDraft`，因此动画草稿与最终的富消息保持一致。普通回复（纯文本、粗体/斜体、简单列表）仍走 MarkdownV2 路径，以在各客户端保持一致的字重和间距。
     
    -当内容超过 32,768 字节的富文本上限时，富消息路径会自动跳过；Telegram 的任何拒绝（较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多）都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发（不会产生重复的最终消息）。
    +当内容超过 32,768 字符的富文本上限时，富消息路径会自动跳过；Telegram 的任何拒绝（较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多）都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发（不会产生重复的最终消息）。
     
     **MarkdownV2 回退。** 当某条消息无法使用富消息路径时，Hermes 会将 markdown 转换为 MarkdownV2。由于 MarkdownV2 没有原生表格语法，管道表格会被规范化：
     
     - **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。
     - **较大或较宽的表格**回退为带对齐列的**围栏代码块**，以防内容折叠。
     
    -富消息默认关闭，因为一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差。若你的客户端能良好处理富消息，可以选择启用：
    +富消息**默认启用**。一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差；若要关闭并强制所有回复走旧版 MarkdownV2 路径：
     
     ```yaml
     gateway:
       platforms:
         telegram:
           extra:
    -        rich_messages: true
    +        rich_messages: false
     ```
     
     这个设置用于客户端渲染兼容性；当 Telegram 拒绝富消息 API 调用时，Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为，可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化（默认：`true`）。
    ```
- **#45995: fix(telegram): reserve rich messages for rich markdown**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `a6364bfa08`, `#46890`, `#46055`, `#46206`

## Fix status

- Merged to main in PR #46890 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-16`.
