{"repo":"NousResearch/hermes-agent","public_id":"7934","display_label":"Streaming messages losing reply-to thread context","summary":"These issues and fixes concern streaming mode dropping the reply_to context on first messages, affecting thread behavior across Telegram, Discord, and Slack gateways. Multiple fix attempts address passing reply_to correctly during streaming.","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":23437,"merged_at":"2026-05-10T22:20:40+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-10T22:20:40+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":6969,"pr_number":23437,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-10T22:20:40+00:00"},{"issue_number":7355,"pr_number":23437,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-10T22:20:40+00:00"},{"issue_number":7934,"pr_number":23437,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-10T22:20:40+00:00"},{"issue_number":9916,"pr_number":23437,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-10T22:20:40+00:00"}],"members":[{"kind":"issue","number":7934,"title":"Streaming mode breaks reply_to_mode for Telegram/Discord","url":null,"state":"closed"},{"kind":"pr","number":7953,"title":"fix(gateway): pass reply_to to streaming messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ff14666cdc02ebad18a15de57ded4e9ec7d9f563","number":0},{"kind":"pr","value":"#23437","number":23437},{"kind":"pr","value":"#9654","number":9654},{"kind":"pr","value":"#10381","number":10381}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":23437,"title":"fix(gateway): stream consumer first message drops thread context (salvage of #13077)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/feishu.py","gateway/run.py","gateway/stream_consumer.py","scripts/release.py","tests/gateway/test_stream_consumer_thread_routing.py"],"fix_hunks":[{"file":"gateway/platforms/feishu.py","hunks":["@@ -4273,21 +4273,31 @@ async def _send_raw_message(\n             request = self._build_reply_message_request(effective_reply_to, body)\n             return await asyncio.to_thread(self._client.im.v1.message.reply, request)\n \n-        body = self._build_create_message_body(\n-            receive_id=chat_id,\n-            msg_type=msg_type,\n-            content=payload,\n-            uuid_value=str(uuid.uuid4()),\n-        )\n-        # Detect whether chat_id is a user open_id (DM) or a chat_id (group).\n-        # Feishu API expects receive_id_type=\"open_id\" for user DMs (ou_ prefix)\n-        # and receive_id_type=\"chat_id\" for group chats (oc_ prefix, which IS\n-        # the chat_id format — see https://open.feishu.cn/document/).\n-        if chat_id.startswith(\"ou_\"):\n-            receive_id_type = \"open_id\"\n+        # For topic/thread messages that fell back from reply→create, use\n+        # thread_id as receive_id so the message lands in the topic instead of\n+        # the main chat.\n+        _thread_id = (metadata or {}).get(\"thread_id\")\n+        if _thread_id:\n+            body = self._build_create_message_body(\n+                receive_id=_thread_id,\n+                msg_type=msg_type,\n+                content=payload,\n+                uuid_value=str(uuid.uuid4()),\n+            )\n+            request = self._build_create_message_request(\"thread_id\", body)\n         else:\n-            receive_id_type = \"chat_id\"\n-        request = self._build_create_message_request(receive_id_type, body)\n+            body = self._build_create_message_body(\n+                receive_id=chat_id,\n+                msg_type=msg_type,\n+                content=payload,\n+                uuid_value=str(uuid.uuid4()),\n+            )\n+            # Detect whether chat_id is a user open_id (DM) or a chat_id (group).\n+            if chat_id.startswith(\"ou_\"):\n+                receive_id_type = \"open_id\"\n+            else:\n+                receive_id_type = \"chat_id\"\n+            request = self._build_create_message_request(receive_id_type, body)\n         return await asyncio.to_thread(self._client.im.v1.message.create, request)\n \n     @staticmethod"]},{"file":"gateway/run.py","hunks":["@@ -13891,6 +13891,7 @@ def _run_still_current() -> bool:\n                         chat_id=source.chat_id,\n                         config=_consumer_cfg,\n                         metadata=_thread_metadata,\n+                        initial_reply_to_id=event_message_id,\n                     )\n             except Exception as _sc_err:\n                 logger.debug(\"Proxy: could not set up stream consumer: %s\", _sc_err)","@@ -14716,6 +14717,7 @@ def run_sync():\n                                 if progress_queue is not None\n                                 else None\n                             ),\n+                            initial_reply_to_id=event_message_id,\n                         )\n                         if _want_stream_deltas:\n                             def _stream_delta_cb(text: str) -> None:"]},{"file":"gateway/stream_consumer.py","hunks":["@@ -92,6 +92,7 @@ def __init__(\n         config: Optional[StreamConsumerConfig] = None,\n         metadata: Optional[dict] = None,\n         on_new_message: Optional[callable] = None,\n+        initial_reply_to_id: Optional[str] = None,\n     ):\n         self.adapter = adapter\n         self.chat_id = chat_id","@@ -105,6 +106,7 @@ def __init__(\n         # the content, not edit the old bubble above it.\n         # Called with no arguments. Exceptions are swallowed.\n         self._on_new_message = on_new_message\n+        self._initial_reply_to_id = initial_reply_to_id\n         self._queue: queue.Queue = queue.Queue()\n         self._accumulated = \"\"\n         self._message_id: Optional[str] = None","@@ -363,7 +365,7 @@ async def run(self) -> None:\n                             self._accumulated, _safe_limit\n                         )\n                         chunks_delivered = False\n-                        reply_to = self._message_id\n+                        reply_to = self._message_id or self._initial_reply_to_id\n                         for chunk in chunks:\n                             new_id = await self._send_new_chunk(chunk, reply_to)\n                             if new_id is not None and new_id != reply_to:","@@ -1004,10 +1006,12 @@ async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:\n                     # The final response will be sent by the fallback path.\n                     return False\n             else:\n-                # First message — send new\n+                # First message — send new, threaded to the original user message\n+                # so it lands in the correct topic/thread.\n                 result = await self.adapter.send(\n                     chat_id=self.chat_id,\n                     content=text,\n+                    reply_to=self._initial_reply_to_id,\n                     metadata=self.metadata,\n                 )\n                 if result.success:"]},{"file":"scripts/release.py","hunks":["@@ -59,6 +59,8 @@\n     \"50326054+nocturnum91@users.noreply.github.com\": \"nocturnum91\",\n     \"223003280+Abd0r@users.noreply.github.com\": \"Abd0r\",\n     \"HuangYuChuh@users.noreply.github.com\": \"HuangYuChuh\",\n+    \"[redacted:email]\": \"hrygo\",\n+    \"26729613+hrygo@users.noreply.github.com\": \"hrygo\",\n     \"[redacted:email]\": \"Abd0r\",\n     \"[redacted:email]\": \"AJV20\",\n     \"[redacted:email]\": \"masonjames\","]},{"file":"tests/gateway/test_stream_consumer_thread_routing.py","hunks":["@@ -0,0 +1,229 @@\n+\"\"\"Regression tests for stream consumer thread/topic routing fix.\n+\n+Verifies that GatewayStreamConsumer correctly passes reply_to on the first\n+message send, ensuring messages land in the correct topic/thread instead of\n+the main group chat.\n+\n+Covers: #6969, #9916, #7355\n+\"\"\"\n+import asyncio\n+from unittest.mock import AsyncMock, MagicMock, patch\n+from types import SimpleNamespace\n+\n+import pytest\n+\n+from gateway.stream_consumer import (\n+    GatewayStreamConsumer,\n+    StreamConsumerConfig,\n+)\n+\n+\n+def _make_adapter(send_result=None, edit_result=None, max_length=4096):\n+    adapter = MagicMock()\n+    adapter.send = AsyncMock(\n+        return_value=send_result or SimpleNamespace(success=True, message_id=\"msg_1\")\n+    )\n+    adapter.edit_message = AsyncMock(\n+        return_value=edit_result or SimpleNamespace(success=True)\n+    )\n+    adapter.MAX_MESSAGE_LENGTH = max_length\n+    return adapter\n+\n+\n+class TestInitialReplyToId:\n+    \"\"\"Verify initial_reply_to_id is passed as reply_to on first send.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_first_send_uses_initial_reply_to_id(self):\n+        \"\"\"When initial_reply_to_id is set, first adapter.send() should\n+        include reply_to=initial_reply_to_id.\"\"\"\n+        adapter = _make_adapter()\n+        consumer = GatewayStreamConsumer(\n+            adapter,\n+            \"chat_123\",\n+            metadata={\"thread_id\": \"omt_topic123\"},\n+            initial_reply_to_id=\"om_user_msg_456\",\n+        )\n+        await consumer._send_or_edit(\"Hello world\")\n+\n+        adapter.send.assert_called_once()\n+        call_kwargs = adapter.send.call_args[1]\n+        assert call_kwargs[\"reply_to\"] == \"om_user_msg_456\", (\n+            \"First send should pass initial_reply_to_id as reply_to\"\n+        )\n+        assert call_kwargs[\"chat_id\"] == \"chat_123\"\n+\n+    @pytest.mark.asyncio\n+    async def test_first_send_without_initial_reply_to_id(self):\n+        \"\"\"When initial_reply_to_id is None, first send should have\n+        reply_to=None (backward compatible).\"\"\"\n+        adapter = _make_adapter()\n+        consumer = GatewayStreamConsumer(\n+            adapter,\n+            \"chat_123\",\n+        )\n+        await consumer._send_or_edit(\"Hello world\")\n+\n+        adapter.send.assert_called_once()\n+        call_kwargs = adapter.send.call_args[1]\n+        assert call_kwargs.get(\"reply_to\") is None\n+\n+    @pytest.mark.asyncio\n+    async def test_subsequent_edits_ignore_initial_reply_to_id(self):\n+        \"\"\"After first send, edits should use message_id, not initial_reply_to_id.\"\"\"\n+        adapter = _make_adapter()\n+        consumer = GatewayStreamConsumer(\n+            adapter,\n+            \"chat_123\",\n+            metadata={\"thread_id\": \"omt_topic123\"},\n+            initial_reply_to_id=\"om_user_msg_456\",\n+        )\n+\n+        # First send\n+        await consumer._send_or_edit(\"Hello world\")\n+        assert adapter.send.call_count == 1\n+\n+        # Second call should edit, not send\n+        await consumer._send_or_edit(\"Hello world updated\")\n+        assert adapter.send.call_count == 1, \"Should edit, not send again\"\n+        adapter.edit_message.assert_called_once()\n+        edit_kwargs = adapter.edit_message.call_args[1]\n+        assert edit_kwargs[\"message_id\"] == \"msg_1\"\n+        assert edit_kwargs[\"chat_id\"] == \"chat_123\"\n+\n+    @pytest.mark.asyncio\n+    async def test_metadata_passed_on_first_send(self):\n+        \"\"\"Metadata (containing thread_id) should be forwarded on first send.\"\"\"\n+        adapter = _make_adapter()\n+        metadata = {\"thread_id\": \"omt_topic789\"}\n+        consumer = GatewayStreamConsumer(\n+            adapter,\n+            \"chat_123\",\n+            metadata=metadata,\n+            initial_reply_to_id=\"om_msg_000\",\n+        )\n+        await consumer._send_or_edit(\"Test\")\n+\n+        call_kwargs = adapter.send.call_args[1]\n+        assert call_kwargs[\"metadata\"] == metadata\n+\n+\n+class TestOverflowFirstMessage:\n+    \"\"\"Verify thread routing is preserved when the first message overflows.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_overflow_first_send_uses_initial_reply_to_id(self):\n+        \"\"\"When first message exceeds platform limit and is split into chunks,\n+        each chunk should be threaded to initial_reply_to_id, not None.\"\"\"\n+        adapter = _make_adapter(max_length=10)\n+        adapter.truncate_message = MagicMock(\n+            return_value=[\"chunk_1\", \"chunk_2\"]\n+        )\n+        consumer = GatewayStreamConsumer(\n+            adapter,\n+            \"chat_123\",\n+            metadata={\"thread_id\": \"omt_topic123\"},\n+            initial_reply_to_id=\"om_user_msg_789\",\n+        )\n+\n+        # Inject oversized accumulated text to trigger overflow path\n+        consumer._accumulated = \"A\" * 100\n+        consumer._current_edit_interval = 999\n+        await consumer._send_new_chunk(\"chunk_1\", consumer._message_id or consumer._initial_reply_to_id)\n+\n+        adapter.send.assert_called_once()\n+        call_kwargs = adapter.send.call_args[1]\n+        assert call_kwargs[\"reply_to\"] == \"om_user_msg_789\", (\n+            \"Overflow first chunk should use initial_reply_to_id\"\n+        )\n+\n+\n+class TestFeishuFallbackThreadRouting:\n+    \"\"\"Verify FeishuAdapter._send_raw_message routes to topic on fallback.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_create_uses_thread_id_when_available(self):\n+        \"\"\"When reply_to=None and metadata has thread_id, message.create\n+        should use receive_id_type='thread_id'.\"\"\"\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        # We test the _send_raw_message method directly by mocking the client\n+        adapter = MagicMock(spec=FeishuAdapter)\n+\n+        # Set up the real _send_raw_message logic manually\n+        mock_client = MagicMock()\n+        mock_create_response = SimpleNamespace(\n+            success=lambda: True,\n+            data=SimpleNamespace(message_id=\"new_msg_1\"),\n+        )\n+        mock_client.im.v1.message.create = MagicMock(return_value=mock_create_response)\n+\n+        # Use the real implementation path\n+        adapter._client = mock_client\n+        adapter._build_create_message_body = FeishuAdapter._build_create_message_body\n+        adapter._build_create_message_request = FeishuAdapter._build_create_message_request\n+\n+        # Call _send_raw_message with reply_to=None and thread_id in metadata\n+        import json\n+        result = await FeishuAdapter._send_raw_message(\n+            adapter,\n+            chat_id=\"oc_main_chat\",\n+            msg_type=\"text\",\n+            payload=json.dumps({\"text\": \"hello\"}),\n+            reply_to=None,\n+            metadata={\"thread_id\": \"omt_topic_abc\"},\n+        )\n+\n+        # Verify message.create was called (not message.reply)\n+        mock_client.im.v1.message.create.assert_called_once()\n+\n+        # The request should have receive_id_type=\"thread_id\"\n+        call_args = mock_client.im.v1.message.create.call_args[0][0]\n+        # Lark SDK builder exposes .body; the in-tree fallback exposes .request_body.\n+        # The contributor's branch had the lark SDK installed, the test environment\n+        # may not — handle both shapes.\n+        body = getattr(call_args, \"body\", None) or getattr(call_args, \"request_body\", None)\n+        assert body is not None, \"request has neither .body nor .request_body\"\n+        # receive_id should be the thread_id, not the chat_id\n+        receive_id = getattr(body, \"receive_id\", None)\n+        if receive_id is None and isinstance(body, str):\n+            import json as _json\n+            receive_id = _json.loads(body).get(\"receive_id\")\n+        assert receive_id == \"omt_topic_abc\", (\n+            f\"Expected receive_id='omt_topic_abc', got '{receive_id}'\"\n+        )\n+        # And receive_id_type must be 'thread_id', not 'chat_id'\n+        receive_id_type = getattr(call_args, \"receive_id_type\", None)\n+        assert receive_id_type == \"thread_id\", (\n+            f\"Expected receive_id_type='thread_id', got '{receive_id_type}'\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_create_uses_chat_id_when_no_thread(self):\n+        \"\"\"When reply_to=None and metadata has no thread_id, message.create\n+        should use receive_id_type='chat_id' (original behavior).\"\"\"\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        mock_client = MagicMock()\n+        mock_create_response = SimpleNamespace(\n+            success=lambda: True,\n+            data=SimpleNamespace(message_id=\"new_msg_1\"),\n+        )\n+        mock_client.im.v1.message.create = MagicMock(return_value=mock_create_response)\n+\n+        adapter = MagicMock(spec=FeishuAdapter)\n+        adapter._client = mock_client\n+        adapter._build_create_message_body = FeishuAdapter._build_create_message_body\n+        adapter._build_create_message_request = FeishuAdapter._build_create_message_request\n+\n+        import json\n+        result = await FeishuAdapter._send_raw_message(\n+            adapter,\n+            chat_id=\"oc_main_chat\",\n+            msg_type=\"text\",\n+            payload=json.dumps({\"text\": \"hello\"}),\n+            reply_to=None,\n+            metadata=None,\n+        )\n+\n+        mock_client.im.v1.message.create.assert_called_once()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}