{"repo":"NousResearch/hermes-agent","public_id":"45911","display_label":"Telegram rich message rendering fixes and consistency","summary":"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.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":46890,"merged_at":"2026-06-16T12:26:05+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-16T12:26:05+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":45911,"pr_number":46206,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-14T18:13:39+00:00"},{"issue_number":45911,"pr_number":46890,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-16T12:26:05+00:00"}],"members":[{"kind":"issue","number":45911,"title":"[Bug]: The rendering of messages in Telegram is off, and not all are consistent.","url":null,"state":"closed"},{"kind":"pr","number":45995,"title":"fix(telegram): reserve rich messages for rich markdown","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a6364bfa08","number":0},{"kind":"pr","value":"#46890","number":46890},{"kind":"pr","value":"#46055","number":46055},{"kind":"pr","value":"#46206","number":46206}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":46206,"title":"fix(telegram): avoid rich final duplicate previews","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"]},{"kind":"pr","number":46890,"title":"fix(telegram): edit streamed previews in place as rich (Bot API 10.1)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"cli-config.yaml.example","hunks":["@@ -724,7 +724,7 @@ platform_toolsets:\n #     # allowed_chats: [\"-1001234567890\"]\n #     extra:\n #       disable_link_previews: false  # Set true to suppress Telegram URL previews in bot messages\n-#       rich_messages: false          # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2\n+#       rich_messages: false          # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2\n #\n # Discord-specific settings (config.yaml top-level, not under platforms:):\n #"]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -419,11 +419,13 @@ def __init__(self, config: PlatformConfig):\n         self._mention_patterns = self._compile_mention_patterns()\n         self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'\n         self._disable_link_previews: bool = self._coerce_bool_extra(\"disable_link_previews\", False)\n-        # Bot API 10.1 Rich Messages: when explicitly enabled, send final\n-        # replies via sendRichMessage with the raw agent markdown so\n-        # tables/task lists/etc. render natively. Disabled by default because\n-        # several Telegram clients accept but render rich messages poorly.\n-        self._rich_messages_enabled: bool = self._coerce_bool_extra(\"rich_messages\", False)\n+        # Bot API 10.1 Rich Messages: render constructs the legacy MarkdownV2\n+        # path degrades (tables → bullet lists, task lists, <details>, block\n+        # math) via sendRichMessage / editMessageText's rich_message param using\n+        # the raw agent markdown. Enabled by default; users can opt out for\n+        # clients that accept but render rich messages poorly via\n+        # platforms.telegram.extra.rich_messages: false.\n+        self._rich_messages_enabled: bool = self._coerce_bool_extra(\"rich_messages\", True)\n         # Latched off after a capability failure on sendRichMessage /\n         # sendRichMessageDraft (e.g. older python-telegram-bot without the\n         # endpoint) so later sends skip the doomed rich attempt entirely.","@@ -979,31 +981,68 @@ def _has_telegram_desktop_details_math_crash_shape(self, content: str) -> bool:\n                 return True\n         return False\n \n-    def _should_attempt_rich(\n-        self, content: str, metadata: Optional[Dict[str, Any]] = None\n-    ) -> bool:\n+    def _needs_rich_rendering(self, content: str) -> bool:\n+        \"\"\"Return True for markdown constructs that the legacy path degrades.\n+\n+        Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram\n+        clients render a consistent font weight/spacing. The rich endpoint is\n+        reserved for constructs where raw markdown materially improves output:\n+        pipe tables (MarkdownV2 has no table syntax and rewrites them into\n+        bullet lists), GFM task lists, collapsible ``<details>`` blocks, and\n+        block math.  Adapted from #45995 (@YonganZhang).\n+        \"\"\"\n+        if not content:\n+            return False\n+        if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()):\n+            return True\n+        if re.search(r\"(?m)^\\s*[-*]\\s+\\[[ xX]\\]\\s+\", content):\n+            return True\n+        if re.search(r\"(?m)^<details\\b|^</details>|^<summary\\b|^</summary>\", content):\n+            return True\n+        if \"$$\" in content:\n+            return True\n+        return False\n+\n+    def _rich_eligible(self, content: str) -> bool:\n+        \"\"\"Capability/content eligibility for rich, ignoring ``expect_edits``.\n+\n+        Shared core of :meth:`_should_attempt_rich` minus the per-call\n+        ``expect_edits`` metadata gate.  The rich EDIT-finalize path\n+        (:meth:`_try_edit_rich`) needs this: a streamed preview is sent with\n+        ``expect_edits=True`` to stay on the editable path mid-stream, but the\n+        FINAL edit should still upgrade to rich when the content warrants it.\n+        \"\"\"\n         return bool(\n-            getattr(self, \"_rich_messages_enabled\", False)\n+            getattr(self, \"_rich_messages_enabled\", True)\n             and not getattr(self, \"_rich_send_disabled\", False)\n-            and not (metadata or {}).get(\"expect_edits\")\n             and content\n             and content.strip()\n+            and self._needs_rich_rendering(content)\n             and not self._has_telegram_desktop_details_math_crash_shape(content)\n             and self._content_fits_rich_limits(content)\n             and self._bot_supports_rich()\n         )\n \n+    def _should_attempt_rich(\n+        self, content: str, metadata: Optional[Dict[str, Any]] = None\n+    ) -> bool:\n+        return bool(\n+            not (metadata or {}).get(\"expect_edits\")\n+            and self._rich_eligible(content)\n+        )\n+\n     def prefers_fresh_final_streaming(\n         self, content: str, metadata: Optional[Dict[str, Any]] = None\n     ) -> bool:\n         \"\"\"Whether to replace a streamed preview with a fresh rich final.\n \n-        Keep this disabled for Telegram. The fresh-final path briefly shows two\n-        copies of the final answer, then deletes the streaming preview after the\n-        rich send succeeds. That is especially visible on clients that support\n-        rich messages well, and it looks like duplicate delivery at the end of\n-        every streamed turn. Until Telegram rich edits are wired directly, final\n-        streamed replies should edit the existing preview in place.\n+        Disabled for Telegram. The fresh-final path briefly shows two copies of\n+        the final answer, then deletes the streaming preview after the rich send\n+        succeeds — it looks like duplicate delivery at the end of every streamed\n+        turn (the reason #46206 reverted it).  Rich finalize is instead handled\n+        by editing the existing preview in place via Bot API 10.1's\n+        ``editMessageText`` ``rich_message`` parameter (see\n+        :meth:`_try_edit_rich`), so no fresh re-send / delete is needed.\n         \"\"\"\n         return False\n ","@@ -1019,7 +1058,7 @@ def streaming_overflow_limit(self) -> Optional[int]:\n         streams split exactly as before.\n         \"\"\"\n         if (\n-            getattr(self, \"_rich_messages_enabled\", False)\n+            getattr(self, \"_rich_messages_enabled\", True)\n             and not getattr(self, \"_rich_send_disabled\", False)\n             and self._bot_supports_rich()\n         ):","@@ -1207,9 +1246,74 @@ async def _try_send_rich(\n             message_id=str(message_id) if message_id is not None else None,\n         )\n \n+    async def _try_edit_rich(\n+        self,\n+        chat_id: str,\n+        message_id: str,\n+        content: str,\n+    ) -> Optional[SendResult]:\n+        \"\"\"Edit an existing message in place as a rich message (Bot API 10.1).\n+\n+        Uses ``editMessageText`` with the ``rich_message`` parameter so a\n+        streamed preview can finalize as rich (tables/task lists/details/math)\n+        WITHOUT a fresh send + delete — no duplicate preview.  Mirrors\n+        :meth:`_try_send_rich`'s error contract:\n+\n+        - success → ``SendResult(success=True, message_id=...)``\n+        - permanent / capability error → ``None`` (caller falls back to the\n+          legacy MarkdownV2 edit; capability errors latch rich off)\n+        - transient / unknown → ``SendResult(success=False)`` with retry\n+          semantics (the message may already be edited; do NOT legacy-resend)\n+        \"\"\"\n+        payload: Dict[str, Any] = {\n+            \"chat_id\": int(chat_id),\n+            \"message_id\": int(message_id),\n+            \"rich_message\": self._rich_message_payload(content),\n+        }\n+        if getattr(self, \"_disable_link_previews\", False):\n+            payload[\"link_preview_options\"] = {\"is_disabled\": True}\n+        try:\n+            # Raw Bot API result; do not request return_type=Message (PTB does\n+            # not fully model the 10.1 response shape yet — a post-edit parse\n+            # error must not be mistaken for a failed edit).\n+            await self._bot.do_api_request(\"editMessageText\", api_kwargs=payload)\n+        except Exception as exc:\n+            if self._is_rich_fallback_error(exc):\n+                if self._is_rich_capability_error(exc):\n+                    self._rich_send_disabled = True\n+                # \"Message is not modified\" — content identical to the current\n+                # rich message; treat as a successful no-op so the caller does\n+                # not fall through to a redundant legacy edit.\n+                if \"not modified\" in str(exc).lower():\n+                    return SendResult(success=True, message_id=message_id)\n+                logger.debug(\n+                    \"[%s] rich editMessageText rejected (%s) — falling back to MarkdownV2 edit\",\n+                    self.name, exc,\n+                )\n+                return None\n+            if \"not modified\" in str(exc).lower():\n+                return SendResult(success=True, message_id=message_id)\n+            err_str = str(exc).lower()\n+            try:\n+                from telegram.error import TimedOut as _TimedOut\n+            except (ImportError, AttributeError):\n+                _TimedOut = None\n+            is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or \"timed out\" in err_str\n+            is_connect_timeout = self._looks_like_connect_timeout(exc)\n+            logger.warning(\n+                \"[%s] rich editMessageText transient failure (no legacy resend): %s\",\n+                self.name, exc,\n+            )\n+            return SendResult(\n+                success=False,\n+                error=str(exc),\n+                retryable=(is_connect_timeout or not is_timeout),\n+            )\n+        return SendResult(success=True, message_id=message_id)\n+\n     def _should_attempt_rich_draft(self, content: str) -> bool:\n         return bool(\n-            getattr(self, \"_rich_messages_enabled\", False)\n+            getattr(self, \"_rich_messages_enabled\", True)\n             and not getattr(self, \"_rich_send_disabled\", False)\n             and not getattr(self, \"_rich_draft_disabled\", False)\n             and content","@@ -2555,6 +2659,21 @@ async def edit_message(\n         if not self._bot:\n             return SendResult(success=False, error=\"Not connected\")\n \n+        # Rich finalize (Bot API 10.1): when the completed content has\n+        # constructs the legacy MarkdownV2 edit degrades (tables → bullet\n+        # lists, task lists, <details>, block math) and rich is available,\n+        # edit the preview IN PLACE via editMessageText's rich_message param.\n+        # No fresh send + delete → no duplicate preview (the problem #46206\n+        # reverted the fresh-final path for).  Attempted before the 4,096\n+        # overflow pre-flight because the rich text cap is 32,768 — a rich\n+        # table that exceeds the MarkdownV2 limit must not be split into legacy\n+        # chunks.  Falls back to the legacy edit path (overflow split included)\n+        # on capability/permanent rejection.\n+        if finalize and self._rich_eligible(content):\n+            rich_result = await self._try_edit_rich(chat_id, message_id, content)\n+            if rich_result is not None:\n+                return rich_result\n+\n         # Pre-flight: if content already exceeds the limit, split-and-deliver\n         # without round-tripping a doomed edit.\n         if utf16_len(content) > self.MAX_MESSAGE_LENGTH:"]},{"file":"hermes_cli/config.py","hunks":["@@ -1991,7 +1991,7 @@ def _ensure_hermes_home_managed(home: Path):\n         \"channel_prompts\": {},         # Per-chat/topic ephemeral system prompts (topics inherit from parent group)\n         \"allowed_chats\": \"\",           # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)\n         \"extra\": {\n-            \"rich_messages\": False,     # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2\n+            \"rich_messages\": True,      # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2\n         },\n     },\n "]},{"file":"tests/gateway/test_config.py","hunks":["@@ -832,7 +832,7 @@ def test_loads_telegram_rich_messages_from_gateway_platform_extra(self, tmp_path\n \n         assert config.platforms[Platform.TELEGRAM].extra[\"rich_messages\"] is False\n \n-    def test_load_config_default_disables_telegram_rich_messages(self, tmp_path, monkeypatch):\n+    def test_load_config_default_enables_telegram_rich_messages(self, tmp_path, monkeypatch):\n         hermes_home = tmp_path / \".hermes\"\n         hermes_home.mkdir()\n ","@@ -842,7 +842,7 @@ def test_load_config_default_disables_telegram_rich_messages(self, tmp_path, mon\n \n         config = load_config()\n \n-        assert config[\"telegram\"][\"extra\"][\"rich_messages\"] is False\n+        assert config[\"telegram\"][\"extra\"][\"rich_messages\"] is True\n \n     def test_bridges_telegram_extra_base_url_from_config_yaml(self, tmp_path, monkeypatch):\n         hermes_home = tmp_path / \".hermes\""]},{"file":"tests/gateway/test_telegram_rich_messages.py","hunks":["@@ -61,6 +61,8 @@ def _make_adapter(extra=None):\n     bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))\n     bot.send_chat_action = AsyncMock()  # keeps the post-send typing re-trigger quiet\n     bot.send_message_draft = AsyncMock(return_value=True)  # legacy draft fallback\n+    bot.edit_message_text = AsyncMock(return_value=MagicMock(message_id=1))  # legacy edit path\n+    bot.delete_message = AsyncMock(return_value=True)\n     adapter._bot = bot\n     return adapter\n ","@@ -184,7 +186,10 @@ async def test_rich_messages_opt_out_accepts_string_false():\n \n \n @pytest.mark.asyncio\n-async def test_rich_messages_default_is_disabled():\n+async def test_rich_messages_default_is_enabled():\n+    \"\"\"Rich messages are on by default (Bot API 10.1); rich-eligible content\n+    (tables/task lists/details/math) goes through sendRichMessage without the\n+    user having to opt in.\"\"\"\n     config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n     adapter = TelegramAdapter(config)\n     bot = MagicMock()","@@ -195,6 +200,42 @@ async def test_rich_messages_default_is_disabled():\n \n     result = await adapter.send(\"12345\", RICH_CONTENT)\n \n+    assert result.success is True\n+    bot = adapter._bot\n+    assert bot is not None\n+    bot.do_api_request.assert_awaited_once()\n+    bot.send_message.assert_not_called()\n+\n+\n[redacted:email]\n+async def test_rich_messages_can_be_opted_out():\n+    \"\"\"Setting platforms.telegram.extra.rich_messages: false keeps every reply\n+    on the legacy MarkdownV2 path even for rich-eligible content.\"\"\"\n+    config = PlatformConfig(\n+        enabled=True, token=\"[redacted:secret-assignment]\", extra={\"rich_messages\": False}\n+    )\n+    adapter = TelegramAdapter(config)\n+    bot = MagicMock()\n+    bot.do_api_request = AsyncMock(return_value=SimpleNamespace(message_id=123))\n+    bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))\n+    bot.send_chat_action = AsyncMock()\n+    adapter._bot = bot\n+\n+    result = await adapter.send(\"12345\", RICH_CONTENT)\n+\n+    assert result.success is True\n+    bot.do_api_request.assert_not_called()\n+    bot.send_message.assert_awaited()\n+\n+\n[redacted:email]\n+async def test_plain_markdown_stays_on_legacy_path():\n+    \"\"\"Ordinary replies (no table/task-list/details/math) stay on the legacy\n+    MarkdownV2 path for consistent client rendering, even with rich enabled.\"\"\"\n+    adapter = _make_adapter()\n+\n+    result = await adapter.send(\"12345\", \"Hello **there**\\n\\nA normal reply.\")\n+\n     assert result.success is True\n     bot = adapter._bot\n     assert bot is not None","@@ -240,7 +281,9 @@ async def test_oversized_content_skips_rich_and_chunks():\n async def test_rich_limit_is_characters_not_bytes():\n     \"\"\"Telegram's rich limit is UTF-8 characters, not encoded bytes.\"\"\"\n     adapter = _make_adapter()\n-    cjk = \"测\" * 20000  # 20k chars, 60k UTF-8 bytes\n+    # Rich-eligible (table) so the content takes the rich path; the CJK body\n+    # is 20k chars / 60k UTF-8 bytes — over the byte count, under the char cap.\n+    cjk = \"| a | b |\\n|---|---|\\n\" + \"测\" * 20000  # 20k chars, ~60k UTF-8 bytes\n     assert len(cjk.encode(\"utf-8\")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES\n     assert len(cjk) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS\n ","@@ -324,7 +367,9 @@ async def test_real_ptb_endpoint_missing_falls_back_and_latches_off(exc):\n async def test_rich_payload_preserves_link_preview_disable():\n     adapter = _make_adapter(extra={\"disable_link_previews\": True})\n \n-    result = await adapter.send(\"12345\", \"See https://example.com\")\n+    result = await adapter.send(\n+        \"12345\", \"| Link | Note |\\n|---|---|\\n| See https://example.com | x |\"\n+    )\n \n     assert result.success is True\n     api_kwargs = _rich_api_kwargs(adapter)","@@ -575,3 +620,139 @@ async def test_rich_draft_opt_out_uses_legacy():\n     assert bot is not None\n     bot.do_api_request.assert_not_called()\n     bot.send_message_draft.assert_awaited_once()\n+\n+\n+# ----------------------------------------------------------------------------\n+# Rich finalize via editMessageText (Bot API 10.1 rich_message edit param).\n+# Streamed previews finalize by editing the existing message IN PLACE as rich,\n+# so tables/task lists survive without a fresh send + delete (no duplicate).\n+# ----------------------------------------------------------------------------\n+\n+\n+def _rich_edit_kwargs(adapter):\n+    \"\"\"Return the api_kwargs dict from the single editMessageText rich call.\"\"\"\n+    call = adapter._bot.do_api_request.call_args\n+    assert call.args[0] == \"editMessageText\"\n+    return call.kwargs[\"api_kwargs\"]\n+\n+\n[redacted:email]\n+async def test_finalize_edit_uses_rich_for_table_content():\n+    \"\"\"Finalizing a streamed preview whose content is a table edits the\n+    existing message IN PLACE via editMessageText's rich_message param —\n+    no fresh send, no delete, no duplicate.\"\"\"\n+    adapter = _make_adapter()\n+\n+    result = await adapter.edit_message(\n+        \"12345\", \"555\", RICH_CONTENT, finalize=True,\n+    )\n+\n+    assert result.success is True\n+    assert result.message_id == \"555\"  # same message, edited in place\n+    api_kwargs = _rich_edit_kwargs(adapter)\n+    assert api_kwargs[\"message_id\"] == 555\n+    # RAW markdown is passed through so table pipes survive.\n+    assert api_kwargs[\"rich_message\"][\"markdown\"] == RICH_CONTENT\n+    # No fresh send / delete — the whole point of the in-place rich edit.\n+    adapter._bot.edit_message_text.assert_not_called()\n+    adapter._bot.delete_message.assert_not_called()\n+\n+\n[redacted:email]\n+async def test_finalize_edit_plain_content_stays_legacy():\n+    \"\"\"Finalizing plain content (no table/task-list/details/math) uses the\n+    legacy MarkdownV2 edit_message_text path, not the rich edit endpoint.\"\"\"\n+    adapter = _make_adapter()\n+\n+    result = await adapter.edit_message(\n+        \"12345\", \"555\", \"Just a normal answer, no rich constructs.\", finalize=True,\n+    )\n+\n+    assert result.success is True\n+    adapter._bot.do_api_request.assert_not_called()\n+    adapter._bot.edit_message_text.assert_awaited()\n+\n+\n[redacted:email]\n+async def test_finalize_edit_rich_capability_error_falls_back_to_legacy():\n+    \"\"\"A capability error on the rich edit latches rich off and falls back to\n+    the legacy MarkdownV2 edit so the user still gets the final answer.\"\"\"\n+    adapter = _make_adapter()\n+    adapter._bot.do_api_request = AsyncMock(side_effect=PTB_ENDPOINT_NOT_FOUND)\n+\n+    result = await adapter.edit_message(\n+        \"12345\", \"555\", RICH_CONTENT, finalize=True,\n+    )\n+\n+    assert result.success is True\n+    assert adapter._rich_send_disabled is True\n+    adapter._bot.edit_message_text.assert_awaited()\n+\n+\n[redacted:email]\n+async def test_finalize_edit_rich_not_modified_is_success_noop():\n+    \"\"\"'Message is not modified' on a rich edit is a no-op success — must NOT\n+    fall through to a redundant legacy edit.\"\"\"\n+    adapter = _make_adapter()\n+    adapter._bot.do_api_request = AsyncMock(\n+        side_effect=BadRequest(\"Message is not modified\")\n+    )\n+\n+    result = await adapter.edit_message(\n+        \"12345\", \"555\", RICH_CONTENT, finalize=True,\n+    )\n+\n+    assert result.success is True\n+    adapter._bot.edit_message_text.assert_not_called()\n+\n+\n[redacted:email]\n+async def test_non_finalize_edit_never_uses_rich():\n+    \"\"\"Intermediate (non-finalize) stream edits stay on the plain edit path;\n+    rich is only applied on the final edit.\"\"\"\n+    adapter = _make_adapter()\n+\n+    result = await adapter.edit_message(\n+        \"12345\", \"555\", RICH_CONTENT, finalize=False,\n+    )\n+\n+    assert result.success is True\n+    adapter._bot.do_api_request.assert_not_called()\n+    adapter._bot.edit_message_text.assert_awaited()\n+\n+\n[redacted:email]\n+async def test_finalize_edit_opt_out_uses_legacy():\n+    \"\"\"With rich_messages: false, even a table finalizes via the legacy\n+    MarkdownV2 edit path.\"\"\"\n+    adapter = _make_adapter(extra={\"rich_messages\": False})\n+\n+    result = await adapter.edit_message(\n+        \"12345\", \"555\", RICH_CONTENT, finalize=True,\n+    )\n+\n+    assert result.success is True\n+    adapter._bot.do_api_request.assert_not_called()\n+    adapter._bot.edit_message_text.assert_awaited()\n+\n+\n[redacted:email]\n+async def test_finalize_edit_rich_over_markdownv2_limit_not_split():\n+    \"\"\"A rich table that exceeds the 4,096 MarkdownV2 limit but fits the 32,768\n+    rich cap is edited in place as one rich message, NOT split into legacy\n+    chunks.\"\"\"\n+    adapter = _make_adapter()\n+    big_table = \"| a | b |\\n|---|---|\\n\" + \"\\n\".join(\n+        f\"| {'x' * 50} | {'y' * 50} |\" for _ in range(40)\n+    )\n+    assert len(big_table) > TelegramAdapter.MAX_MESSAGE_LENGTH\n+    assert len(big_table) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS\n+\n+    result = await adapter.edit_message(\n+        \"12345\", \"555\", big_table, finalize=True,\n+    )\n+\n+    assert result.success is True\n+    api_kwargs = _rich_edit_kwargs(adapter)\n+    assert api_kwargs[\"rich_message\"][\"markdown\"] == big_table\n+    adapter._bot.edit_message_text.assert_not_called()"]},{"file":"website/docs/user-guide/messaging/telegram.md","hunks":["@@ -900,23 +900,23 @@ gateway:\n \n ## Rendering: Rich Messages, Tables and Link Previews\n \n-**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.\n+**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.\n \n-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).\n+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).\n \n **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:\n \n - **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.\n - **Larger or wider tables** fall back to a **fenced code block** with aligned columns so nothing collapses.\n \n-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:\n+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:\n \n ```yaml\n gateway:\n   platforms:\n     telegram:\n       extra:\n-        rich_messages: true\n+        rich_messages: false\n ```\n \n 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`)."]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md","hunks":["@@ -877,23 +877,23 @@ gateway:\n \n ## 渲染：富消息、表格和链接预览\n \n-**富消息（Bot API 10.1）。** 选择启用后，最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送，使用 Agent 的**原始 markdown**，因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中，实时流式预览也使用 `sendRichMessageDraft`，因此动画草稿与最终的富消息保持一致。\n+**富消息（Bot API 10.1）。** 最终回复中那些会被旧版 MarkdownV2 路径降级的结构——表格、任务列表、可折叠的 `<details>` 以及块级数学公式——会通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送，使用 Agent 的**原始 markdown**，从而原生渲染、无需客户端展平。在流式传输过程中，最终答案通过 `editMessageText` 的 `rich_message` 参数**就地编辑现有预览**来交付——不发第二条消息、不删除，因此一轮结束时不会出现重复投递的闪烁。在私聊中，实时流式预览也使用 `sendRichMessageDraft`，因此动画草稿与最终的富消息保持一致。普通回复（纯文本、粗体/斜体、简单列表）仍走 MarkdownV2 路径，以在各客户端保持一致的字重和间距。\n \n-当内容超过 32,768 字节的富文本上限时，富消息路径会自动跳过；Telegram 的任何拒绝（较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多）都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发（不会产生重复的最终消息）。\n+当内容超过 32,768 字符的富文本上限时，富消息路径会自动跳过；Telegram 的任何拒绝（较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多）都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发（不会产生重复的最终消息）。\n \n **MarkdownV2 回退。** 当某条消息无法使用富消息路径时，Hermes 会将 markdown 转换为 MarkdownV2。由于 MarkdownV2 没有原生表格语法，管道表格会被规范化：\n \n - **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。\n - **较大或较宽的表格**回退为带对齐列的**围栏代码块**，以防内容折叠。\n \n-富消息默认关闭，因为一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差。若你的客户端能良好处理富消息，可以选择启用：\n+富消息**默认启用**。一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差；若要关闭并强制所有回复走旧版 MarkdownV2 路径：\n \n ```yaml\n gateway:\n   platforms:\n     telegram:\n       extra:\n-        rich_messages: true\n+        rich_messages: false\n ```\n \n 这个设置用于客户端渲染兼容性；当 Telegram 拒绝富消息 API 调用时，Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为，可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化（默认：`true`）。\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}