{"repo":"NousResearch/hermes-agent","public_id":"47048","display_label":"Telegram gateway double delivery of final replies","summary":"The members concern a gateway issue where a final reply may be delivered twice on Telegram, overlapping an already-streamed MarkdownV2 message (reported with table and bullet content). Two members are fixes that respect an adapter's decline of a fresh-final message; the third is an issue report describing the overlap symptom.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50413,"merged_at":"2026-06-21T20:55:51+00:00"},"resolution":{"state":"resolved","by":"pr-50413"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T20:55:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":47048,"pr_number":50413,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-21T20:55:51+00:00"}],"members":[{"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":"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":"#47048","number":47048}],"category":"merged_via","implication":"superseded"},"merged":false},{"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"},"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"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}