NousResearch/hermes-agent

Streaming final delivery timing and deduplication fixes

Cluster 25010 · gateway. Data as of

3 open items. Fix merged in #33793. Check it covers your case.

The short answer

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

These issues and PRs address bugs in streaming message delivery across gateways (Telegram, email, Feishu), focusing on premature final-delivery flags, duplicate or suppressed sends, and platform-specific rendering edge cases. No single adapter is the sole focus; the unifying theme is correct confirmation and sequencing of streamed final content.

Open issues1

  • Issue#51605Email adapter: double reply sent when previewed=True but content_delivered=False

Open PRs2

  • PR#49910fix(gateway): keep streamed interim edits in one message
  • PR#51617fix(gateway): keep email interim chatter opt-in

Fixes on record

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

  • #33793 fix(gateway): only confirm final content delivery when the final send succeeds

    merged to main

    files: gateway/stream_consumer.py, tests/gateway/test_stream_consumer.py

    Show the diff
    • gateway/stream_consumer.py
      @@ -552,11 +552,6 @@ async def run(self) -> None:
                           self._last_edit_time = time.monotonic()
       
                       if got_done:
      -                    # Record that the final content reached the user even
      -                    # if the cosmetic final edit below fails.
      -                    if current_update_visible and self._accumulated:
      -                        self._final_content_delivered = True
      -
                           # Final edit without cursor. If progressive editing failed
                           # mid-stream, send a single continuation/fallback message
                           # here instead of letting the base gateway path send the
      @@ -573,6 +568,7 @@ async def run(self) -> None:
                                   # final edit — but only for adapters that don't
                                   # need an explicit finalize signal.
                                   self._final_response_sent = True
      +                            self._final_content_delivered = True
                               elif self._message_id:
                                   # Either the mid-stream edit didn't run (no
                                   # visible update this tick) OR the adapter needs
      @@ -580,8 +576,12 @@ async def run(self) -> None:
                                   self._final_response_sent = await self._send_or_edit(
                                       self._accumulated, finalize=True,
                                   )
      +                            if self._final_response_sent:
      +                                self._final_content_delivered = True
                               elif not self._already_sent:
                                   self._final_response_sent = await self._send_or_edit(self._accumulated)
      +                            if self._final_response_sent:
      +                                self._final_content_delivered = True
                           return
       
                       if commentary_text is not None:
      @@ -641,6 +641,7 @@ async def run(self) -> None:
                   # "Let me search…") had been delivered, not the real answer.
                   if _best_effort_ok and not self._final_response_sent:
                       self._final_response_sent = True
      +                self._final_content_delivered = True
               except Exception as e:
                   logger.error("Stream consumer error: %s", e)
       
      @@ -778,6 +779,7 @@ async def _send_fallback_final(self, text: str) -> None:
                               pass
                       self._already_sent = True
                       self._final_response_sent = True
      +                self._final_content_delivered = True
                       return
       
               raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
      @@ -814,11 +816,13 @@ async def _send_fallback_final(self, text: str) -> None:
       
                   if not result or not result.success:
                       if sent_any_chunk:
      -                    # Some continuation text already reached the user. Suppress
      -                    # the base gateway final-send path so we don't resend the
      -                    # full response and create another duplicate.
      +                    # Some continuation text already reached the user, but not
      +                    # the full response. Do NOT set _final_response_sent — the
      +                    # base gateway final-send path should still deliver the
      +                    # complete response so the user gets the full answer.
      +                    # Suppress only _already_sent to avoid a duplicate send
      +                    # of the same partial content.
                           self._already_sent = True
      -                    self._final_response_sent = True
                           self._message_id = last_message_id
                           self._last_sent_text = last_successful_chunk
                           self._fallback_prefix = ""
      @@ -856,6 +860,7 @@ async def _send_fallback_final(self, text: str) -> None:
               self._message_id = last_message_id
               self._already_sent = True
               self._final_response_sent = True
      +        self._final_content_delivered = True
               self._last_sent_text = chunks[-1]
               self._fallback_prefix = ""
       
    • tests/gateway/test_stream_consumer.py
      @@ -939,6 +939,133 @@ async def test_split_overflow_partial_send_marks_final_sent(self):
               assert consumer._final_response_sent is True
       
       
      +class TestFinalContentDeliveredGuard:
      +    """Regression coverage for #25010 — _final_content_delivered must only be
      +    set when the final response is actually confirmed delivered to the user,
      +    not when a mid-stream edit happened to show partial content.  Prematurely
      +    setting this flag causes the gateway to suppress the normal final send,
      +    leaving the user with an incomplete partial message."""
      +
      +    @pytest.mark.asyncio
      +    async def test_mid_stream_edit_success_does_not_mark_content_delivered(self):
      +        """When the mid-stream edit with finalize=True succeeds but the
      +        subsequent finalize edit fails, _final_content_delivered must stay
      +        False so the gateway does not suppress its fallback send (#25010).
      +
      +        Simulates TelegramAdapter which sets REQUIRES_EDIT_FINALIZE=True,
      +        requiring a second finalize edit even when content is unchanged."""
      +        adapter = MagicMock()
      +        adapter.REQUIRES_EDIT_FINALIZE = True  # Telegram adapter behavior
      +        # First send (initial streaming message) succeeds
      +        # Mid-stream finalize edit succeeds
      +        # Final finalize edit FAILS (e.g. flood control on Telegram)
      +        adapter.edit_message = AsyncMock(side_effect=[
      +            SimpleNamespace(success=True),   # mid-stream edit
      +            SimpleNamespace(success=True),   # finalize edit on line 548
      +            SimpleNamespace(success=False),  # final finalize on line 580 (FAILS)
      +        ])
      +        adapter.send = AsyncMock(
      +            return_value=SimpleNamespace(success=True, message_id="msg_1"),
      +        )
      +        adapter.MAX_MESSAGE_LENGTH = 4096
      +
      +        config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
      +        consumer = GatewayStreamConsumer(adapter, "chat_123", config)
      +
      +        # Simulate streaming: send initial text, then more text, then done
      +        consumer.on_delta("Part one of the response...\n")
      +        task = asyncio.create_task(consumer.run())
      +        await asyncio.sleep(0.05)
      +
      +        consumer.on_delta("Part two, the complete final answer.\n")
      +        await asyncio.sleep(0.05)
      +
      +        consumer.finish()
      +        await task
      +
      +        # The key assertion: _final_content_delivered must NOT be True,
      +        # because the final edit failed and the complete response was never
      +        # confirmed delivered.
      +        assert consumer._final_content_delivered is False, (
      +            "_final_content_delivered was prematurely set to True — gateway "
      +            "will wrongly suppress its fallback send, leaving the user with "
      +            "an incomplete partial message (#25010)"
      +        )
      +        # The gateway must still be allowed to send the complete response
      +        assert consumer._final_response_sent is False, (
      +            "_final_response_sent must also be False when the final edit failed"
      +        )
      +
      +    @pytest.mark.asyncio
      +    async def test_final_edit_success_does_mark_content_delivered(self):
      +        """When the final finalize edit succeeds, _final_content_delivered
      +        must be True — the normal happy path should still work."""
      +        adapter = MagicMock()
      +        adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))
      +        adapter.send = AsyncMock(
      +            return_value=SimpleNamespace(success=True, message_id="msg_1"),
      +        )
      +        adapter.MAX_MESSAGE_LENGTH = 4096
      +
      +        config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
      +        consumer = GatewayStreamConsumer(adapter, "chat_123", config)
      +
      +        consumer.on_delta("The complete response.\n")
      +        task = asyncio.create_task(consumer.run())
      +        await asyncio.sleep(0.05)
      +
      +        consumer.finish()
      +        await task
      +
      +        assert consumer._final_content_delivered is True, (
      +            "_final_content_delivered must be True when the final edit succeeds"
      +        )
      +        assert consumer._final_response_sent is True
      +
      +    @pytest.mark.asyncio
      +    async def test_fallback_partial_send_does_not_mark_final_sent(self):
      +        """When fallback final send delivers only some chunks before failing,
      +        _final_response_sent must stay False so the gateway can still attempt
      +        a complete final send (#25010)."""
      +        call_count = 0
      +
      +        async def fake_send(*, chat_id, content, **kwargs):
      +            nonlocal call_count
      +            call_count += 1
      +            if call_count <= 2:
      +                return SimpleNamespace(success=True, message_id="msg_1")
      +            # Third chunk (fallback continuation) FAILS
      +            return SimpleNamespace(success=False, error="flood_control:13.0")
      +
      +        adapter = MagicMock()
      +        adapter.send = AsyncMock(side_effect=fake_send)
      +        adapter.edit_message = AsyncMock(
      +            return_value=SimpleNamespace(success=False, error="flood_control:13.0"),
      +        )
      +        adapter.MAX_MESSAGE_LENGTH = 4096
      +
      +        config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
      +        consumer = GatewayStreamConsumer(adapter, "chat_123", config)
      +
      +        # Trigger enough delta to enter fallback mode
      +        consumer.on_delta("Initial streaming text...\n")
      +        task = asyncio.create_task(consumer.run())
      +        await asyncio.sleep(0.05)
      +
      +        # Send a very long text that will trigger overflow/fallback
      +        long_text = ("x" * 3000 + "\n") + ("y" * 3000 + "\n") + "Final answer.\n"
      +        consumer.on_delta(long_text)
      +        await asyncio.sleep(0.1)
      +
      +        consumer.finish()
      +        await task
      +
      +        assert consumer._final_response_sent is False, (
      +            "Partial fallback send must not set _final_response_sent — gateway "
      +            "must still be able to deliver the complete response (#25010)"
      +        )
      +
      +
       class TestEditOverflowSplitAndDeliver:
           """When edit_message split-and-delivers an oversized payload across the
           original message + N continuations (Telegram >4096 UTF-16), the consumer
  • #50322 fix(telegram): make Bot API 10.1 rich messages opt-in (default off) (#48422)

    merged to main

    files: 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

    Show the diff
    • cli-config.yaml.example
      @@ -730,7 +730,7 @@ platform_toolsets:
       #     # allowed_chats: ["-1001234567890"]
       #     extra:
       #       disable_link_previews: false  # Set true to suppress Telegram URL previews in bot messages
      -#       rich_messages: false          # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2
      +#       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
       #
       # Discord-specific settings (config.yaml top-level, not under platforms:):
       #
    • hermes_cli/config.py
      @@ -2158,7 +2158,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": True,      # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2
      +            "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.
               },
           },
       
    • plugins/platforms/telegram/adapter.py
      @@ -489,10 +489,14 @@ def __init__(self, config: PlatformConfig):
               # 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
      +        # the raw agent markdown. Disabled by default so Telegram messages stay
      +        # easy to copy as plain text; users can opt in for richer rendering on
               # 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)
      +        # platforms.telegram.extra.rich_messages: true.  Keep this opt-in:
      +        # current Telegram clients can make rich messages difficult to copy
      +        # as plain text, which is worse than degraded table/task-list rendering
      +        # for command snippets and mobile handoffs.
      +        self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
               # 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.
    • scripts/release.py
      @@ -45,6 +45,7 @@
       
       # Auto-extracted from noreply emails + manual overrides
       AUTHOR_MAP = {
      +    "[redacted:email]": "kn8-codes",  # PR #48422 salvage (rich messages opt-in default off)
           "[redacted:email]": "de1tydev",  # PR #10158 salvage (poll read-only for notify_on_complete watcher; #10156)
           "[redacted:email]": "szzhoujiarui-sketch",  # cron model.default salvage co-author (#45550)
           "[redacted:email]": "rayjun",  # cron model.default salvage co-author (#43952)
    • tests/gateway/test_config.py
      @@ -881,7 +881,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_enables_telegram_rich_messages(self, tmp_path, monkeypatch):
      +    def test_load_config_default_keeps_telegram_rich_messages_opt_in(self, tmp_path, monkeypatch):
               hermes_home = tmp_path / ".hermes"
               hermes_home.mkdir()
       
      @@ -891,7 +891,7 @@ def test_load_config_default_enables_telegram_rich_messages(self, tmp_path, monk
       
               config = load_config()
       
      -        assert config["telegram"]["extra"]["rich_messages"] is True
      +        assert config["telegram"]["extra"]["rich_messages"] is False
       
           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
      @@ -210,10 +210,10 @@ async def test_rich_messages_opt_out_accepts_string_false():
       
       
       @pytest.mark.asyncio
      -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."""
      +async def test_rich_messages_default_is_legacy_copyable_path():
      +    """Rich messages stay opt-in because current Telegram clients can make
      +    Bot API rich messages hard to copy as plain text. Rich-eligible content
      +    defaults to the legacy MarkdownV2 path unless the user opts in."""
           config = PlatformConfig(enabled=True, token="[redacted:secret-assignment]")
           adapter = TelegramAdapter(config)
           bot = MagicMock()
      @@ -224,6 +224,29 @@ async def test_rich_messages_default_is_enabled():
       
           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_not_called()
      +    bot.send_message.assert_awaited()
      +
      +
      [redacted:email]
      +async def test_rich_messages_can_be_opted_in():
      +    """Setting platforms.telegram.extra.rich_messages: true enables native
      +    Bot API rich rendering for tables/task lists/details/math."""
      +    config = PlatformConfig(
      +        enabled=True, token="[redacted:secret-assignment]", extra={"rich_messages": True}
      +    )
      +    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 = adapter._bot
           assert bot is not None
    • website/docs/user-guide/messaging/telegram.md
      @@ -940,17 +940,17 @@ The rich path is skipped automatically when content exceeds the 32,768-character
       - **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 **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:
      +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:
       
       ```yaml
       gateway:
         platforms:
           telegram:
             extra:
      -        rich_messages: false
      +        rich_messages: true
       ```
       
      -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`).
      +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`).
       
       **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.):
       
    • website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md
      @@ -886,17 +886,17 @@ gateway:
       - **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。
       - **较大或较宽的表格**回退为带对齐列的**围栏代码块**,以防内容折叠。
       
      -富消息**默认启用**。一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差;若要关闭并强制所有回复走旧版 MarkdownV2 路径:
      +富消息现在是**选择启用**。默认保持旧版 MarkdownV2 路径,因为当前 Telegram 客户端可能让 Bot API 富消息难以作为纯文本复制,这对命令片段和移动端交接尤其麻烦。若要为表格、任务列表、折叠 `<details>` 和块级数学启用原生渲染:
       
       ```yaml
       gateway:
         platforms:
           telegram:
             extra:
      -        rich_messages: false
      +        rich_messages: true
       ```
       
      -这个设置用于客户端渲染兼容性;当 Telegram 拒绝富消息 API 调用时,Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为,可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化(默认:`true`)。
      +这个设置用于客户端渲染/复制兼容性;当 Telegram 拒绝富消息 API 调用时,Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为,可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化(默认:`true`)。
       
       **链接预览。** Telegram 会为机器人消息中的 URL 自动生成链接预览。如果你希望抑制这些预览(长 `/tools` 输出、提及十个链接的 Agent 回复等):
       
  • #62476 fix(telegram): recover final delivery after stream flood

    merged to main

    files: gateway/stream_consumer.py, plugins/platforms/telegram/adapter.py, tests/gateway/test_telegram_final_delivery.py

4 PRs closed without merging

From the close comments: done elsewhere 2 · merged via another change 1 · partial fix 1

  • #25051 fix(gateway): reconcile partial streamed finals · partial fix → 9179396, #33793, #25010, #31821
  • #31821 fix(stream-consumer): only set _final_content_delivered when final response is confirmed delivered · merged via another change → 9179396, #33793
  • #48900 fix(telegram): allow per-chat rich message fallback · done elsewhere → #50322
  • #59864 fix(telegram): commit streamed final when preview may disappear · done elsewhere → 0489863, 4aa499f, #62476
Closed issues · 4
  • Closed#25010Telegram streaming can leave incomplete partial message while final send is suppressed
  • Closed#48841Telegram Desktop does not render sendRichMessageDraft streams (iOS/Android work fine)
  • Closed#49687[Bug]: Feishu streaming card: stream_consumer final_content_delivered flagged too early, causing "split" visual effect (占位 + 后续补救)
  • Closed#58958Telegram messages disappearing due to deduplication?
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.11: 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
#33793, #50322, #62476
Closed issues
4
Attempts
4 PRs closed without merging
Fix link checked
11 Jul 2026

For your agent

The same facts, typed.