{"repo":"NousResearch/hermes-agent","public_id":"19863","display_label":"Telegram Rich Messages support, table rendering, and streaming finalization","summary":"Most members concern Telegram Bot API 10.x Rich Messages in the gateway: requests for support, table rendering that falls back to bullets or code blocks, draft streaming (including DM topics), and final-message overlap or fallback behavior. A few members are adjacent, such as the send_message tool, a markdown library swap, and a delivery fix.","subsystem":"agent","counts":{"members":38,"open_issues":6,"open_issues_distinct":6,"open_prs":3,"closed_issues":15,"merged_prs":2,"attempt_prs":15},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50322},"resolution":{"state":"partially_resolved","by":"pr-50413"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T20:55:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.043527738264580354},"fix_edges":[{"issue_number":47048,"pr_number":50413,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":48841,"pr_number":50322,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":49536,"pr_number":50322,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":19863,"title":"[Feature]: Telegram table formatting using Telegram Mini Apps","url":null,"state":"closed"},{"kind":"issue","number":24171,"title":"Replace regex-based format_message() with telegramify-markdown","url":null,"state":"closed"},{"kind":"issue","number":44428,"title":"[Feature]: Support Telegram Bot API 10.1 Rich Messages and rich draft streaming","url":null,"state":"closed"},{"kind":"issue","number":45013,"title":"Add optional Telegram Bot API 10.1 Rich Messages support for structured AI output","url":null,"state":"closed"},{"kind":"issue","number":45323,"title":"[Bug]: Telegram rich tables are rewritten into bullets by the shared formatter","url":null,"state":"closed"},{"kind":"issue","number":45770,"title":"Telegram Rich Message draft streaming does not work in private DM topics","url":null,"state":"closed"},{"kind":"issue","number":45854,"title":"Feature Request: Add sendRichMessage support to send_message tool for Telegram","url":null,"state":"open"},{"kind":"issue","number":45864,"title":"Feature: Support Telegram Bot API 10.1 Rich Messages","url":null,"state":"closed"},{"kind":"issue","number":46009,"title":"Telegram Rich Messages (Bot API 10.1+) — edit_message stream destroys rich formatting","url":null,"state":"closed"},{"kind":"issue","number":46234,"title":"gateway(telegram): support Bot API 10.1 Rich Messages (tables, nested lists, inline media)","url":null,"state":"closed"},{"kind":"issue","number":46515,"title":"Telegram: sendRichMessageDraft works but final message falls back to MarkdownV2 (expect_edits kills rich path)","url":null,"state":"open"},{"kind":"issue","number":46540,"title":"[Bug]: Draft streaming — final message falls back to MarkdownV2 instead of Rich Message","url":null,"state":"closed"},{"kind":"issue","number":47048,"title":"Telegram: rich-message final reply overlaps the already-streamed legacy MarkdownV2 message (table + bullet double render)","url":null,"state":"closed"},{"kind":"issue","number":47095,"title":"[gateway] Add support for Telegram Bot API 10.1 RichBlockTable (native tables) in messaging pipeline","url":null,"state":"closed"},{"kind":"issue","number":47762,"title":"send_message tool does not render Rich Messages (tables, checklists) — only cron auto-delivery works","url":null,"state":"open"},{"kind":"issue","number":48667,"title":"Track PTB Bot API 10.1 delay and Telegram Rich Message compatibility shim","url":null,"state":"open"},{"kind":"issue","number":49137,"title":"rich_messages: true - streaming preview shows table but final sendMessage falls back to bullet list","url":null,"state":"closed"},{"kind":"issue","number":49452,"title":"[Telegram] Markdown pipe tables converted to bullet lists in streaming finalization","url":null,"state":"closed"},{"kind":"issue","number":49536,"title":"Bug: Telegram finalize message text overlap due to parse_mode mutation","url":null,"state":"open"},{"kind":"issue","number":50218,"title":"[Telegram] _strip_mdv2 fallback strips **bold** markers, breaking markdown tables","url":null,"state":"closed"},{"kind":"issue","number":64497,"title":"feat(telegram): support Telegram Bot API 10.2","url":null,"state":"open"},{"kind":"pr","number":24203,"title":"feat : replace telegram markdownV2 regex formatting with telegramify-markdown","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":45327,"title":"fix(telegram): preserve pipe tables on rich-message and standalone send paths","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"5600105478ffde29d7566b45421b100eaa29c4ef","number":0},{"kind":"pr","value":"#45323","number":45323}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":45387,"title":"fix(gateway): preserve tables in rich Telegram sends and send_message (#45323)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45327","number":45327},{"kind":"pr","value":"#45323","number":45323}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45956,"title":"fix(telegram): use direct_messages_topic_id for draft streaming in DM topics","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"194729c95f","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":46025,"title":"feat(telegram): add Rich Message support (Bot API 10.1+)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":46438,"title":"fix(send_message): support Telegram rich messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46118","number":46118},{"kind":"pr","value":"#45854","number":45854},{"kind":"pr","value":"#46118","number":46118},{"kind":"pr","value":"#46263","number":46263},{"kind":"pr","value":"#46317","number":46317}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":46536,"title":"fix(gateway): omit expect_edits on finalize so Telegram uses sendRichMessage","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":47073,"title":"fix(gateway): respect adapter decline of fresh-final to prevent Telegram double delivery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b8911cd6cd6e50e677774c780adfd5d42fccde2c","number":0},{"kind":"pr","value":"#50413","number":50413},{"kind":"pr","value":"#50413","number":50413},{"kind":"pr","value":"#47048","number":47048},{"kind":"pr","value":"#50413","number":50413}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":49258,"title":"feat(gateway/telegram): convert pipe tables to native HTML tables via sendRichMessage","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9d225fbf4eaf3cc8aa490e12ccf1145123793677","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":49537,"title":"fix(telegram): route all finalize through rich edit to avoid parse_mode overlap","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50322","number":50322}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":49850,"title":"fix(telegram): replace table-to-bullet conversion with monospaced code block","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":50239,"title":"fix(telegram): preserve markdown formatting in fallback paths","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":50322,"title":"fix(telegram): make Bot API 10.1 rich messages opt-in (default off) (#48422)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli-config.yaml.example","hermes_cli/config.py","plugins/platforms/telegram/adapter.py","scripts/release.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":["@@ -730,7 +730,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          # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2\n+#       rich_messages: false          # Bot API 10.1 rich messages (tables/task lists/details/math); default false for copyable legacy MarkdownV2, set true to opt in\n #\n # Discord-specific settings (config.yaml top-level, not under platforms:):\n #"]},{"file":"hermes_cli/config.py","hunks":["@@ -2158,7 +2158,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\": True,      # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2\n+            \"rich_messages\": False,     # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set True to opt in. Default stays legacy MarkdownV2 because rich messages can be hard to copy as plain text in Telegram clients.\n         },\n     },\n "]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -489,10 +489,14 @@ def __init__(self, config: PlatformConfig):\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+        # the raw agent markdown. Disabled by default so Telegram messages stay\n+        # easy to copy as plain text; users can opt in for richer rendering on\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+        # platforms.telegram.extra.rich_messages: true.  Keep this opt-in:\n+        # current Telegram clients can make rich messages difficult to copy\n+        # as plain text, which is worse than degraded table/task-list rendering\n+        # for command snippets and mobile handoffs.\n+        self._rich_messages_enabled: bool = self._coerce_bool_extra(\"rich_messages\", False)\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."]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"kn8-codes\",  # PR #48422 salvage (rich messages opt-in default off)\n     \"[redacted:email]\": \"de1tydev\",  # PR #10158 salvage (poll read-only for notify_on_complete watcher; #10156)\n     \"[redacted:email]\": \"szzhoujiarui-sketch\",  # cron model.default salvage co-author (#45550)\n     \"[redacted:email]\": \"rayjun\",  # cron model.default salvage co-author (#43952)"]},{"file":"tests/gateway/test_config.py","hunks":["@@ -881,7 +881,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_enables_telegram_rich_messages(self, tmp_path, monkeypatch):\n+    def test_load_config_default_keeps_telegram_rich_messages_opt_in(self, tmp_path, monkeypatch):\n         hermes_home = tmp_path / \".hermes\"\n         hermes_home.mkdir()\n ","@@ -891,7 +891,7 @@ def test_load_config_default_enables_telegram_rich_messages(self, tmp_path, monk\n \n         config = load_config()\n \n-        assert config[\"telegram\"][\"extra\"][\"rich_messages\"] is True\n+        assert config[\"telegram\"][\"extra\"][\"rich_messages\"] is False\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":["@@ -210,10 +210,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_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+async def test_rich_messages_default_is_legacy_copyable_path():\n+    \"\"\"Rich messages stay opt-in because current Telegram clients can make\n+    Bot API rich messages hard to copy as plain text. Rich-eligible content\n+    defaults to the legacy MarkdownV2 path unless the user opts in.\"\"\"\n     config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n     adapter = TelegramAdapter(config)\n     bot = MagicMock()","@@ -224,6 +224,29 @@ async def test_rich_messages_default_is_enabled():\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_not_called()\n+    bot.send_message.assert_awaited()\n+\n+\n[redacted:email]\n+async def test_rich_messages_can_be_opted_in():\n+    \"\"\"Setting platforms.telegram.extra.rich_messages: true enables native\n+    Bot API rich rendering for tables/task lists/details/math.\"\"\"\n+    config = PlatformConfig(\n+        enabled=True, token=\"[redacted:secret-assignment]\", extra={\"rich_messages\": True}\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 = adapter._bot\n     assert bot is not None"]},{"file":"website/docs/user-guide/messaging/telegram.md","hunks":["@@ -940,17 +940,17 @@ The rich path is skipped automatically when content exceeds the 32,768-character\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 **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+Rich messages are **opt-in**. The default stays on the legacy MarkdownV2 path because current Telegram clients can make Bot API rich messages difficult to copy as plain text, which is especially painful for command snippets and mobile handoffs. To enable native rendering for tables/task lists/details/math:\n \n ```yaml\n gateway:\n   platforms:\n     telegram:\n       extra:\n-        rich_messages: false\n+        rich_messages: true\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`).\n+This setting is for client-rendering/copy 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`).\n \n **Link previews.** Telegram auto-generates link previews for URLs in bot messages. If you'd rather suppress those (long `/tools` output, agent reply that mentions ten links, etc.):\n "]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md","hunks":["@@ -886,17 +886,17 @@ gateway:\n - **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。\n - **较大或较宽的表格**回退为带对齐列的**围栏代码块**，以防内容折叠。\n \n-富消息**默认启用**。一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差；若要关闭并强制所有回复走旧版 MarkdownV2 路径：\n+富消息现在是**选择启用**。默认保持旧版 MarkdownV2 路径，因为当前 Telegram 客户端可能让 Bot API 富消息难以作为纯文本复制，这对命令片段和移动端交接尤其麻烦。若要为表格、任务列表、折叠 `<details>` 和块级数学启用原生渲染：\n \n ```yaml\n gateway:\n   platforms:\n     telegram:\n       extra:\n-        rich_messages: false\n+        rich_messages: true\n ```\n \n-这个设置用于客户端渲染兼容性；当 Telegram 拒绝富消息 API 调用时，Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为，可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化（默认：`true`）。\n+这个设置用于客户端渲染/复制兼容性；当 Telegram 拒绝富消息 API 调用时，Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为，可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化（默认：`true`）。\n \n **链接预览。** Telegram 会为机器人消息中的 URL 自动生成链接预览。如果你希望抑制这些预览（长 `/tools` 输出、提及十个链接的 Agent 回复等）：\n \n"]}]},{"kind":"pr","number":50413,"title":"fix(gateway): respect adapter decline of fresh-final to prevent double delivery (#47048)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/stream_consumer.py","tests/gateway/test_stream_consumer.py"],"fix_hunks":[{"file":"gateway/stream_consumer.py","hunks":["@@ -1440,11 +1440,37 @@ async def _send_or_edit(\n                     # finalizing through edit would visibly downgrade a rich\n                     # preview, so re-deliver as a fresh message + delete the\n                     # preview instead.\n+                    #\n+                    # When the adapter exposes prefers_fresh_final_streaming\n+                    # and explicitly returns False, the time-based threshold\n+                    # must NOT override that decision.  On Telegram the\n+                    # fresh-final path sends a Rich Message (sendRichMessage)\n+                    # that overlaps with the legacy MarkdownV2 preview already\n+                    # visible from streaming — both remain on screen because\n+                    # the old message is only best-effort deleted.  Adapters\n+                    # without the hook still get the time-based fresh-final.\n+                    # (#47048)\n+                    # Check the *class* for the hook so MagicMock adapters\n+                    # (which auto-create attributes on access) are not\n+                    # falsely detected as having it.  Also check instance\n+                    # __dict__ for test doubles that explicitly assign the\n+                    # attribute (e.g. adapter.prefers_fresh_final_streaming\n+                    # = MagicMock(return_value=False)).\n+                    _has_prefers_hook = (\n+                        hasattr(type(self.adapter),\n+                                \"prefers_fresh_final_streaming\")\n+                        or \"prefers_fresh_final_streaming\"\n+                            in getattr(self.adapter, \"__dict__\", {})\n+                    )\n+                    _prefers_fresh = self._adapter_prefers_fresh_final(text)\n                     if (\n                         finalize\n                         and (\n-                            self._should_send_fresh_final()\n-                            or self._adapter_prefers_fresh_final(text)\n+                            _prefers_fresh\n+                            or (\n+                                not _has_prefers_hook\n+                                and self._should_send_fresh_final()\n+                            )\n                         )\n                         and await self._try_fresh_final(\n                             text, is_turn_final=is_turn_final,"]},{"file":"tests/gateway/test_stream_consumer.py","hunks":["@@ -2009,3 +2009,106 @@ def test_codepoint_only_adapter_falls_back_to_len(self):\n         # this file passing — they all use MagicMock adapters.\n         assert consumer is not None\n \n+\n+class TestFreshFinalRespectsAdapterDecline:\n+    \"\"\"Regression: when an adapter explicitly declines fresh-final via\n+    ``prefers_fresh_final_streaming = False``, the time-based\n+    ``_should_send_fresh_final()`` must NOT override that decision.\n+    (#47048 — Telegram rich-message overlap with legacy MarkdownV2 preview)\n+    \"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_adapter_decline_fresh_final_overrides_time_threshold(self):\n+        \"\"\"Adapter with prefers_fresh_final_streaming=False must NOT take\n+        the fresh-final path even when fresh_final_after_seconds is large.\"\"\"\n+        adapter = MagicMock()\n+        adapter.MAX_MESSAGE_LENGTH = 4096\n+        adapter.send = AsyncMock(\n+            return_value=SimpleNamespace(success=True, message_id=\"rich_msg\"),\n+        )\n+        adapter.edit_message = AsyncMock(\n+            return_value=SimpleNamespace(success=True, message_id=\"edit_msg\"),\n+        )\n+        adapter.delete_message = AsyncMock(return_value=True)\n+        # Adapter explicitly declines fresh-final (like Telegram)\n+        adapter.prefers_fresh_final_streaming = MagicMock(return_value=False)\n+\n+        config = StreamConsumerConfig(\n+            edit_interval=0.01,\n+            buffer_threshold=5,\n+            fresh_final_after_seconds=1.0,  # time threshold would trigger\n+            cursor=\" ▉\",\n+        )\n+        consumer = GatewayStreamConsumer(adapter, \"chat_123\", config)\n+\n+        # Simulate: first message sent during streaming\n+        consumer.on_delta(\"Hello world\")\n+        task = asyncio.create_task(consumer.run())\n+        await asyncio.sleep(0.05)\n+        # First message should have been sent\n+        assert consumer._message_id is not None\n+        # Simulate time passing (beyond threshold)\n+        consumer._message_created_ts -= 10.0\n+\n+        # Finalize\n+        consumer.on_delta(\"Hello world final\")\n+        consumer.finish()\n+        await task\n+\n+        # The adapter declined fresh-final, so send() should NOT have been\n+        # called for the final message — only edit_message(finalize=True).\n+        adapter.send.assert_called_once()  # Only the initial send\n+        adapter.edit_message.assert_called()  # Finalize edit\n+        # Verify edit was called with finalize=True\n+        edit_calls = [\n+            c for c in adapter.edit_message.call_args_list\n+            if c.kwargs.get(\"finalize\") or (len(c.args) > 3 and c.args[3])\n+        ]\n+        assert len(edit_calls) >= 1, (\n+            \"Expected finalize=True edit call, got none\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_no_hook_adapter_uses_time_threshold(self):\n+        \"\"\"Adapter WITHOUT prefers_fresh_final_streaming must still use\n+        the time-based fresh-final path (backward compat).\"\"\"\n+        adapter = MagicMock()\n+        adapter.MAX_MESSAGE_LENGTH = 4096\n+        adapter.send = AsyncMock(\n+            return_value=SimpleNamespace(success=True, message_id=\"msg_1\"),\n+        )\n+        adapter.edit_message = AsyncMock(\n+            return_value=SimpleNamespace(success=True, message_id=\"edit_msg\"),\n+        )\n+        adapter.delete_message = AsyncMock(return_value=True)\n+        # No prefers_fresh_final_streaming attribute\n+        if hasattr(adapter, \"prefers_fresh_final_streaming\"):\n+            del adapter.prefers_fresh_final_streaming\n+\n+        config = StreamConsumerConfig(\n+            edit_interval=0.01,\n+            buffer_threshold=5,\n+            fresh_final_after_seconds=1.0,\n+            cursor=\" ▉\",\n+        )\n+        consumer = GatewayStreamConsumer(adapter, \"chat_123\", config)\n+\n+        # Simulate: first message sent during streaming\n+        consumer.on_delta(\"Hello world\")\n+        task = asyncio.create_task(consumer.run())\n+        await asyncio.sleep(0.05)\n+        assert consumer._message_id is not None\n+        # Simulate time passing\n+        consumer._message_created_ts -= 10.0\n+\n+        # Finalize\n+        consumer.on_delta(\"Hello world final\")\n+        consumer.finish()\n+        await task\n+\n+        # Without the hook, time-based fresh-final should trigger:\n+        # send() called twice (initial + fresh-final)\n+        assert adapter.send.call_count == 2, (\n+            f\"Expected 2 send calls (initial + fresh-final), got {adapter.send.call_count}\"\n+        )\n+\n"]}]},{"kind":"pr","number":50516,"title":"fix(telegram): resolve parse_mode mutation overlap in rich edit-finalize path","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50512","number":50512},{"kind":"pr","value":"#50512","number":50512},{"kind":"pr","value":"#49537","number":49537},{"kind":"pr","value":"#49536","number":49536}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":53377,"title":"docs(gateway/telegram): add operator test recipe + dry-run script for Rich Messages Bot API 10.1","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#44428","number":44428},{"kind":"pr","value":"#45864","number":45864}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":62715,"title":"fix: ensure rich messages are used for final messages when notify=true (#49137)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}