{"repo":"NousResearch/hermes-agent","public_id":"12839","display_label":"Telegram forum topic thread ID routing fixes","summary":"Multiple fixes and a bug report addressing send_model_picker bypassing _message_thread_id_for_send(), causing message routing failures in Telegram forum topics and DMs. The members share a single focused theme around correcting thread ID handling across several send paths.","subsystem":null,"counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22410},"resolution":{"state":"resolved","by":"pr-22410"},"freshness":{"state":"verified","last_verified_at":"2026-05-09T08:39:37+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":12839,"pr_number":22410,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":22022,"pr_number":22410,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":12839,"title":"[Bug]: send_model_picker bypasses _message_thread_id_for_send — \"Message thread not found\" in topic groups","url":null,"state":"closed"},{"kind":"pr","number":12853,"title":"fix(telegram): route model-picker thread_id through _message_thread_id_for_send","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12839","number":12839}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":12856,"title":"fix(telegram): use _message_thread_id_for_send in send_model_picker","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":12873,"title":"fix: use _message_thread_id_for_send() in send_model_picker() for forum topics","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22410","number":22410},{"kind":"pr","value":"#22053","number":22053},{"kind":"pr","value":"#22053","number":22053},{"kind":"pr","value":"#22423","number":22423},{"kind":"pr","value":"#22267","number":22267}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":18612,"title":"fix: use _message_thread_id_for_send in send_model_picker","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22410","number":22410},{"kind":"pr","value":"#22053","number":22053},{"kind":"pr","value":"#22053","number":22053},{"kind":"pr","value":"#22423","number":22423},{"kind":"pr","value":"#22267","number":22267}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":22410,"title":"fix(telegram): preserve DM topic routing via reply fallback (salvage #22053)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/platforms/telegram.py","gateway/run.py","tests/gateway/test_background_command.py","tests/gateway/test_telegram_thread_fallback.py","tests/gateway/test_voice_command.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -40,6 +40,52 @@ def _platform_name(platform) -> str:\n     return str(value or \"\").lower()\n \n \n+def _thread_metadata_for_source(source, reply_to_message_id: str | None = None) -> dict | None:\n+    \"\"\"Build platform-aware thread metadata for adapter sends.\n+\n+    Most platforms route threaded sends with a generic ``thread_id`` metadata\n+    value. Telegram private-chat topics created through Hermes' DM-topic helper\n+    are exposed in updates as ``message_thread_id`` plus a reply anchor, but\n+    outbound sends only render in the correct Telegram lane when the adapter\n+    supplies both ``message_thread_id`` and ``reply_to_message_id``. Mark those\n+    lanes so the Telegram adapter can avoid the known-bad partial routes.\n+    \"\"\"\n+    thread_id = getattr(source, \"thread_id\", None)\n+    if thread_id is None:\n+        return None\n+    metadata = {\"thread_id\": thread_id}\n+    if _platform_name(getattr(source, \"platform\", None)) == \"telegram\" and getattr(source, \"chat_type\", None) == \"dm\":\n+        metadata[\"telegram_dm_topic_reply_fallback\"] = True\n+        anchor = reply_to_message_id or getattr(source, \"message_id\", None)\n+        if anchor is not None:\n+            metadata[\"telegram_reply_to_message_id\"] = str(anchor)\n+    return metadata\n+\n+\n+def _reply_anchor_for_event(event) -> str | None:\n+    \"\"\"Return reply_to id for platforms that need reply semantics.\n+\n+    Telegram forum/supergroup topics should be routed by topic metadata, not by\n+    replying to the triggering message. Hermes-created Telegram private-chat\n+    topic lanes are different: Bot API sends reject their ``message_thread_id``\n+    and do not route with ``direct_messages_topic_id``. Those lanes only remain\n+    visible when sent with both the private topic thread id and a reply to the\n+    triggering user message.\n+    \"\"\"\n+    source = getattr(event, \"source\", None)\n+    platform = _platform_name(getattr(source, \"platform\", None))\n+    thread_id = getattr(source, \"thread_id\", None)\n+    if platform == \"telegram\" and thread_id and getattr(source, \"chat_type\", None) == \"dm\":\n+        # Reply to the triggering user message. Replying to Telegram's earlier\n+        # topic seed/anchor can render the bot response outside the active lane.\n+        return getattr(event, \"message_id\", None) or getattr(event, \"reply_to_message_id\", None)\n+    if platform == \"telegram\" and thread_id:\n+        return None\n+    if platform == \"feishu\" and thread_id and getattr(event, \"reply_to_message_id\", None):\n+        return getattr(event, \"reply_to_message_id\", None)\n+    return getattr(event, \"message_id\", None)\n+\n+\n def should_send_media_as_audio(platform, ext: str, is_voice: bool = False) -> bool:\n     \"\"\"Return True when a media file should use the platform's audio sender.\n ","@@ -1719,7 +1765,7 @@ async def send_image(\n         \"\"\"\n         # Fallback: send URL as text (subclasses override for native images)\n         text = f\"{caption}\\n{image_url}\" if caption else image_url\n-        return await self.send(chat_id=chat_id, content=text, reply_to=reply_to)\n+        return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)\n     \n     async def send_animation(\n         self,","@@ -1798,6 +1844,7 @@ async def send_voice(\n         audio_path: str,\n         caption: Optional[str] = None,\n         reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n         **kwargs,\n     ) -> SendResult:\n         \"\"\"","@@ -1810,7 +1857,7 @@ async def send_voice(\n         text = f\"🔊 Audio: {audio_path}\"\n         if caption:\n             text = f\"{caption}\\n{text}\"\n-        return await self.send(chat_id=chat_id, content=text, reply_to=reply_to)\n+        return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)\n \n     async def play_tts(\n         self,","@@ -1832,6 +1879,7 @@ async def send_video(\n         video_path: str,\n         caption: Optional[str] = None,\n         reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n         **kwargs,\n     ) -> SendResult:\n         \"\"\"","@@ -1843,7 +1891,7 @@ async def send_video(\n         text = f\"🎬 Video: {video_path}\"\n         if caption:\n             text = f\"{caption}\\n{text}\"\n-        return await self.send(chat_id=chat_id, content=text, reply_to=reply_to)\n+        return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)\n \n     async def send_document(\n         self,","@@ -1852,6 +1900,7 @@ async def send_document(\n         caption: Optional[str] = None,\n         file_name: Optional[str] = None,\n         reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n         **kwargs,\n     ) -> SendResult:\n         \"\"\"","@@ -1863,7 +1912,7 @@ async def send_document(\n         text = f\"📎 File: {file_path}\"\n         if caption:\n             text = f\"{caption}\\n{text}\"\n-        return await self.send(chat_id=chat_id, content=text, reply_to=reply_to)\n+        return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)\n \n     async def send_image_file(\n         self,","@@ -1871,6 +1920,7 @@ async def send_image_file(\n         image_path: str,\n         caption: Optional[str] = None,\n         reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n         **kwargs,\n     ) -> SendResult:\n         \"\"\"","@@ -1883,7 +1933,7 @@ async def send_image_file(\n         text = f\"🖼️ Image: {image_path}\"\n         if caption:\n             text = f\"{caption}\\n{text}\"\n-        return await self.send(chat_id=chat_id, content=text, reply_to=reply_to)\n+        return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)\n \n     @staticmethod\n     def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:","@@ -2558,7 +2608,7 @@ async def _dispatch_active_session_command(\n         current_guard = self._active_sessions.get(session_key)\n         command_guard = asyncio.Event()\n         self._active_sessions[session_key] = command_guard\n-        thread_meta = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n+        thread_meta = _thread_metadata_for_source(event.source, _reply_anchor_for_event(event))\n \n         try:\n             response = await self._message_handler(event)","@@ -2579,13 +2629,7 @@ async def _dispatch_active_session_command(\n                 _r = await self._send_with_retry(\n                     chat_id=event.source.chat_id,\n                     content=_text,\n-                    reply_to=(\n-                        event.reply_to_message_id\n-                        if event.source.platform == Platform.FEISHU\n-                        and event.source.thread_id\n-                        and event.reply_to_message_id\n-                        else event.message_id\n-                    ),\n+                    reply_to=_reply_anchor_for_event(event),\n                     metadata=thread_meta,\n                 )\n                 if _eph_ttl > 0 and _r.success and _r.message_id:","@@ -2678,20 +2722,14 @@ async def handle_message(self, event: MessageEvent) -> None:\n                     self.name, cmd, session_key,\n                 )\n                 try:\n-                    _thread_meta = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n+                    _thread_meta = _thread_metadata_for_source(event.source, _reply_anchor_for_event(event))\n                     response = await self._message_handler(event)\n                     _text, _eph_ttl = self._unwrap_ephemeral(response)\n                     if _text:\n                         _r = await self._send_with_retry(\n                             chat_id=event.source.chat_id,\n                             content=_text,\n-                            reply_to=(\n-                                event.reply_to_message_id\n-                                if event.source.platform == Platform.FEISHU\n-                                and event.source.thread_id\n-                                and event.reply_to_message_id\n-                                else event.message_id\n-                            ),\n+                            reply_to=_reply_anchor_for_event(event),\n                             metadata=_thread_meta,\n                         )\n                         if _eph_ttl > 0 and _r.success and _r.message_id:","@@ -2783,7 +2821,7 @@ def _record_delivery(result):\n         self._active_sessions[session_key] = interrupt_event\n         \n         # Start continuous typing indicator (refreshes every 2 seconds)\n-        _thread_metadata = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n+        _thread_metadata = _thread_metadata_for_source(event.source, _reply_anchor_for_event(event))\n         _keep_typing_kwargs = {\"metadata\": _thread_metadata}\n         try:\n             _keep_typing_sig = inspect.signature(self._keep_typing)","@@ -2911,11 +2949,7 @@ async def _stop_typing_task() -> None:\n                 # Send the text portion\n                 if text_content:\n                     logger.info(\"[%s] Sending response (%d chars) to %s\", self.name, len(text_content), event.source.chat_id)\n-                    _reply_anchor = (\n-                        event.reply_to_message_id\n-                        if event.source.platform == Platform.FEISHU and event.source.thread_id and event.reply_to_message_id\n-                        else event.message_id\n-                    )\n+                    _reply_anchor = _reply_anchor_for_event(event)\n                     result = await self._send_with_retry(\n                         chat_id=event.source.chat_id,\n                         content=text_content,","@@ -3108,7 +3142,7 @@ async def _stop_typing_task() -> None:\n             try:\n                 error_type = type(e).__name__\n                 error_detail = str(e)[:300] if str(e) else \"no details available\"\n-                _thread_metadata = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n+                _thread_metadata = _thread_metadata_for_source(event.source, _reply_anchor_for_event(event))\n                 await self.send(\n                     chat_id=event.source.chat_id,\n                     content=("]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -361,6 +361,63 @@ def _metadata_thread_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str\n         thread_id = metadata.get(\"thread_id\") or metadata.get(\"message_thread_id\")\n         return str(thread_id) if thread_id is not None else None\n \n+    @classmethod\n+    def _metadata_direct_messages_topic_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]:\n+        if not metadata:\n+            return None\n+        topic_id = metadata.get(\"direct_messages_topic_id\") or metadata.get(\"telegram_direct_messages_topic_id\")\n+        return str(topic_id) if topic_id is not None else None\n+\n+    @classmethod\n+    def _metadata_reply_to_message_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[int]:\n+        if not metadata:\n+            return None\n+        reply_to = metadata.get(\"telegram_reply_to_message_id\")\n+        return int(reply_to) if reply_to is not None else None\n+\n+    @classmethod\n+    def _reply_to_message_id_for_send(\n+        cls,\n+        reply_to: Optional[str],\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> Optional[int]:\n+        if reply_to:\n+            return int(reply_to)\n+        if metadata and metadata.get(\"telegram_dm_topic_reply_fallback\"):\n+            return cls._metadata_reply_to_message_id(metadata)\n+        return None\n+\n+    @classmethod\n+    def _thread_kwargs_for_send(\n+        cls,\n+        chat_id: str,\n+        thread_id: Optional[str],\n+        metadata: Optional[Dict[str, Any]] = None,\n+        reply_to_message_id: Optional[int] = None,\n+    ) -> Dict[str, Any]:\n+        \"\"\"Return Telegram send kwargs for forum and direct-message topic routing.\n+\n+        Supergroup/forum topics use ``message_thread_id``. True Bot API Direct\n+        Messages topics can opt in with explicit ``direct_messages_topic_id``\n+        metadata. Hermes-created private-chat topic lanes are marked with\n+        ``telegram_dm_topic_reply_fallback`` and must send the private topic\n+        thread id together with a reply anchor. Live testing showed that either\n+        parameter alone can render outside the visible lane.\n+        \"\"\"\n+        if metadata and metadata.get(\"telegram_dm_topic_reply_fallback\"):\n+            if reply_to_message_id is None:\n+                reply_to_message_id = cls._metadata_reply_to_message_id(metadata)\n+            if reply_to_message_id is None:\n+                return {}\n+            return {\"message_thread_id\": cls._message_thread_id_for_send(thread_id)}\n+        direct_topic_id = cls._metadata_direct_messages_topic_id(metadata)\n+        if direct_topic_id is not None:\n+            return {\n+                \"message_thread_id\": None,\n+                \"direct_messages_topic_id\": int(direct_topic_id),\n+            }\n+        return {\"message_thread_id\": cls._message_thread_id_for_send(thread_id)}\n+\n     @classmethod\n     def _message_thread_id_for_send(cls, thread_id: Optional[str]) -> Optional[int]:\n         if not thread_id or str(thread_id) == cls._GENERAL_TOPIC_THREAD_ID:","@@ -384,6 +441,65 @@ def _message_thread_id_for_typing(cls, thread_id: Optional[str]) -> Optional[int\n     def _is_thread_not_found_error(error: Exception) -> bool:\n         return \"thread not found\" in str(error).lower()\n \n+    @staticmethod\n+    def _is_bad_request_error(error: Exception) -> bool:\n+        name = error.__class__.__name__.lower()\n+        if name == \"badrequest\" or name.endswith(\"badrequest\"):\n+            return True\n+        try:\n+            from telegram.error import BadRequest\n+            return isinstance(error, BadRequest)\n+        except ImportError:\n+            return False\n+\n+    @classmethod\n+    def _should_retry_without_dm_topic_reply_anchor(\n+        cls,\n+        error: Exception,\n+        metadata: Optional[Dict[str, Any]],\n+        reply_to_message_id: Optional[int],\n+    ) -> bool:\n+        return (\n+            bool(metadata and metadata.get(\"telegram_dm_topic_reply_fallback\"))\n+            and reply_to_message_id is not None\n+            and cls._is_bad_request_error(error)\n+            and \"message to be replied not found\" in str(error).lower()\n+        )\n+\n+    async def _send_with_dm_topic_reply_anchor_retry(\n+        self,\n+        send_fn: Any,\n+        send_kwargs: Dict[str, Any],\n+        metadata: Optional[Dict[str, Any]],\n+        reply_to_message_id: Optional[int],\n+        media_label: str,\n+        reset_media: Optional[Any] = None,\n+    ) -> Any:\n+        \"\"\"Retry stale private-topic media replies once without the topic anchor.\"\"\"\n+        try:\n+            return await send_fn(**send_kwargs)\n+        except Exception as send_err:\n+            if not self._should_retry_without_dm_topic_reply_anchor(\n+                send_err,\n+                metadata,\n+                reply_to_message_id,\n+            ):\n+                raise\n+            logger.warning(\n+                \"[%s] Reply target deleted for Telegram %s, \"\n+                \"retrying without reply/topic anchor: %s\",\n+                self.name,\n+                media_label,\n+                send_err,\n+            )\n+            if reset_media is not None:\n+                reset_media()\n+            retry_kwargs = dict(send_kwargs)\n+            retry_kwargs[\"reply_to_message_id\"] = None\n+            retry_kwargs.pop(\"message_thread_id\", None)\n+            retry_kwargs.pop(\"direct_messages_topic_id\", None)\n+            return await send_fn(**retry_kwargs)\n+\n     def _fallback_ips(self) -> list[str]:\n         \"\"\"Return validated fallback IPs from config (populated by _apply_env_overrides).\"\"\"\n         configured = self.config.extra.get(\"fallback_ips\", []) if getattr(self.config, \"extra\", None) else []","@@ -1254,9 +1370,23 @@ async def send(\n                 _TimedOut = None  # type: ignore[assignment,misc]\n \n             for i, chunk in enumerate(chunks):\n-                should_thread = self._should_thread_reply(reply_to, i)\n-                reply_to_id = int(reply_to) if should_thread else None\n-                effective_thread_id = self._message_thread_id_for_send(thread_id)\n+                metadata_reply_to = self._metadata_reply_to_message_id(metadata)\n+                reply_to_source = reply_to or (\n+                    str(metadata_reply_to)\n+                    if metadata and metadata.get(\"telegram_dm_topic_reply_fallback\") and metadata_reply_to is not None else None\n+                )\n+                if metadata and metadata.get(\"telegram_dm_topic_reply_fallback\"):\n+                    should_thread = reply_to_source is not None\n+                else:\n+                    should_thread = self._should_thread_reply(reply_to_source, i)\n+                reply_to_id = int(reply_to_source) if should_thread and reply_to_source else None\n+                thread_kwargs = self._thread_kwargs_for_send(\n+                    chat_id,\n+                    thread_id,\n+                    metadata,\n+                    reply_to_message_id=reply_to_id,\n+                )\n+                effective_thread_id = thread_kwargs.get(\"message_thread_id\")\n \n                 msg = None\n                 for _send_attempt in range(3):","@@ -1268,7 +1398,7 @@ async def send(\n                                 text=chunk,\n                                 parse_mode=ParseMode.MARKDOWN_V2,\n                                 reply_to_message_id=reply_to_id,\n-                                message_thread_id=effective_thread_id,\n+                                **thread_kwargs,\n                                 **self._link_preview_kwargs(),\n                             )\n                         except Exception as md_error:","@@ -1281,7 +1411,7 @@ async def send(\n                                     text=plain_chunk,\n                                     parse_mode=None,\n                                     reply_to_message_id=reply_to_id,\n-                                    message_thread_id=effective_thread_id,\n+                                    **thread_kwargs,\n                                     **self._link_preview_kwargs(),\n                                 )\n                             else:","@@ -1302,17 +1432,30 @@ async def send(\n                                     self.name, effective_thread_id,\n                                 )\n                                 effective_thread_id = None\n+                                thread_kwargs = {\"message_thread_id\": None}\n                                 continue\n                             err_lower = str(send_err).lower()\n                             if \"message to be replied not found\" in err_lower and reply_to_id is not None:\n                                 # Original message was deleted before we\n-                                # could reply — clear reply target and retry\n-                                # so the response is still delivered.\n+                                # could reply. For private-topic fallback\n+                                # sends, message_thread_id is only valid with\n+                                # the reply anchor, so drop both together.\n                                 logger.warning(\n                                     \"[%s] Reply target deleted, retrying without reply_to: %s\",\n                                     self.name, send_err,\n                                 )\n                                 reply_to_id = None\n+                                if metadata and metadata.get(\"telegram_dm_topic_reply_fallback\"):\n+                                    thread_kwargs = {}\n+                                    effective_thread_id = None\n+                                else:\n+                                    thread_kwargs = self._thread_kwargs_for_send(\n+                                        chat_id,\n+                                        thread_id,\n+                                        metadata,\n+                                        reply_to_message_id=reply_to_id,\n+                                    )\n+                                    effective_thread_id = thread_kwargs.get(\"message_thread_id\")\n                                 continue\n                             # Other BadRequest errors are permanent — don't retry\n                             raise","@@ -1494,13 +1637,19 @@ async def send_update_prompt(\n                 ]\n             ])\n             thread_id = self._metadata_thread_id(metadata)\n-            message_thread_id = self._message_thread_id_for_send(thread_id)\n+            reply_to_id = self._reply_to_message_id_for_send(None, metadata)\n             msg = await self._bot.send_message(\n                 chat_id=int(chat_id),\n                 text=text,\n                 parse_mode=ParseMode.MARKDOWN,\n                 reply_markup=keyboard,\n-                message_thread_id=message_thread_id,\n+                reply_to_message_id=reply_to_id,\n+                **self._thread_kwargs_for_send(\n+                    chat_id,\n+                    thread_id,\n+                    metadata,\n+                    reply_to_message_id=reply_to_id,\n+                ),\n                 **self._link_preview_kwargs(),\n             )\n             return SendResult(success=True, message_id=str(msg.message_id))","@@ -1558,9 +1707,16 @@ async def send_exec_approval(\n                 \"reply_markup\": keyboard,\n                 **self._link_preview_kwargs(),\n             }\n-            message_thread_id = self._message_thread_id_for_send(thread_id)\n-            if message_thread_id is not None:\n-                kwargs[\"message_thread_id\"] = message_thread_id\n+            reply_to_id = self._reply_to_message_id_for_send(None, metadata)\n+            kwargs[\"reply_to_message_id\"] = reply_to_id\n+            kwargs.update(\n+                self._thread_kwargs_for_send(\n+                    chat_id,\n+                    thread_id,\n+                    metadata,\n+                    reply_to_message_id=reply_to_id,\n+                )\n+            )\n \n             msg = await self._bot.send_message(**kwargs)\n ","@@ -1603,9 +1759,16 @@ async def send_slash_confirm(\n                 \"reply_markup\": keyboard,\n                 **self._link_preview_kwargs(),\n             }\n-            message_thread_id = self._message_thread_id_for_send(thread_id)\n-            if message_thread_id is not None:\n-                kwargs[\"message_thread_id\"] = message_thread_id\n+            reply_to_id = self._reply_to_message_id_for_send(None, metadata)\n+            kwargs[\"reply_to_message_id\"] = reply_to_id\n+            kwargs.update(\n+                self._thread_kwargs_for_send(\n+                    chat_id,\n+                    thread_id,\n+                    metadata,\n+                    reply_to_message_id=reply_to_id,\n+                )\n+            )\n \n             msg = await self._bot.send_message(**kwargs)\n             self._slash_confirm_state[confirm_id] = session_key","@@ -1664,12 +1827,19 @@ def get_label(slug):\n             )\n \n             thread_id = metadata.get(\"thread_id\") if metadata else None\n+            reply_to_id = self._reply_to_message_id_for_send(None, metadata)\n             msg = await self._bot.send_message(\n                 chat_id=int(chat_id),\n                 text=text,\n                 parse_mode=ParseMode.MARKDOWN,\n                 reply_markup=keyboard,\n-                message_thread_id=int(thread_id) if thread_id else None,\n+                reply_to_message_id=reply_to_id,\n+                **self._thread_kwargs_for_send(\n+                    chat_id,\n+                    thread_id,\n+                    metadata,\n+                    reply_to_message_id=reply_to_id,\n+                ),\n                 **self._link_preview_kwargs(),\n             )\n ","@@ -2046,17 +2216,47 @@ async def _handle_callback_query(\n                         session_key, confirm_id, choice,\n                     )\n                     if result_text and query.message:\n-                        # Inherit the prompt message's thread so the reply\n-                        # lands in the same supergroup topic / reply chain.\n+                        # Inherit the prompt message's topic. Supergroup forums\n+                        # use message_thread_id; Telegram private DM-topic lanes\n+                        # need both the private topic id and the prompt reply anchor.\n                         thread_id = getattr(query.message, \"message_thread_id\", None)\n+                        chat = getattr(query.message, \"chat\", None)\n+                        chat_type = getattr(chat, \"type\", None)\n+                        prompt_message_id = getattr(query.message, \"message_id\", None)\n                         send_kwargs: Dict[str, Any] = {\n                             \"chat_id\": int(query.message.chat_id),\n                             \"text\": result_text,\n                             \"parse_mode\": ParseMode.MARKDOWN,\n                             **self._link_preview_kwargs(),\n                         }\n-                        if thread_id is not None:\n-                            send_kwargs[\"message_thread_id\"] = thread_id\n+                        chat_type_value = getattr(chat_type, \"value\", chat_type)\n+                        is_private_chat = str(chat_type_value).lower() in {\n+                            \"private\",\n+                            str(ChatType.PRIVATE).lower(),\n+                            str(getattr(ChatType.PRIVATE, \"value\", ChatType.PRIVATE)).lower(),\n+                        }\n+                        if thread_id is not None and is_private_chat and prompt_message_id is not None:\n+                            reply_to_id = int(prompt_message_id)\n+                            send_kwargs[\"reply_to_message_id\"] = reply_to_id\n+                            send_kwargs.update(\n+                                self._thread_kwargs_for_send(\n+                                    str(query.message.chat_id),\n+                                    str(thread_id),\n+                                    {\n+                                        \"thread_id\": str(thread_id),\n+                                        \"telegram_dm_topic_reply_fallback\": True,\n+                                    },\n+                                    reply_to_message_id=reply_to_id,\n+                                )\n+                            )\n+                        elif thread_id is not None:\n+                            send_kwargs.update(\n+                                self._thread_kwargs_for_send(\n+                                    str(query.message.chat_id),\n+                                    str(thread_id),\n+                                    {\"thread_id\": str(thread_id)},\n+                                )\n+                            )\n                         await self._bot.send_message(**send_kwargs)\n                 except Exception as exc:\n                     logger.error(\"[%s] slash-confirm callback failed: %s\", self.name, exc, exc_info=True)","@@ -2137,22 +2337,50 @@ async def send_voice(\n                 # .ogg / .opus files -> send as voice (round playable bubble)\n                 if ext in (\".ogg\", \".opus\"):\n                     _voice_thread = self._metadata_thread_id(metadata)\n-                    msg = await self._bot.send_voice(\n-                        chat_id=int(chat_id),\n-                        voice=audio_file,\n-                        caption=caption[:1024] if caption else None,\n-                        reply_to_message_id=int(reply_to) if reply_to else None,\n-                        message_thread_id=self._message_thread_id_for_send(_voice_thread),\n+                    reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)\n+                    voice_thread_kwargs = self._thread_kwargs_for_send(\n+                        chat_id,\n+                        _voice_thread,\n+                        metadata,\n+                        reply_to_message_id=reply_to_id,\n+                    )\n+                    msg = await self._send_with_dm_topic_reply_anchor_retry(\n+                        self._bot.send_voice,\n+                        {\n+                            \"chat_id\": int(chat_id),\n+                            \"voice\": audio_file,\n+                            \"caption\": caption[:1024] if caption else None,\n+                            \"reply_to_message_id\": reply_to_id,\n+                            **voice_thread_kwargs,\n+                        },\n+                        metadata,\n+                        reply_to_id,\n+                        \"voice\",\n+                        reset_media=lambda: audio_file.seek(0),\n                     )\n                 elif ext in (\".mp3\", \".m4a\"):\n                     # Telegram's Bot API sendAudio only accepts MP3 / M4A.\n                     _audio_thread = self._metadata_thread_id(metadata)\n-                    msg = await self._bot.send_audio(\n-                        chat_id=int(chat_id),\n-                        audio=audio_file,\n-                        caption=caption[:1024] if caption else None,\n-                        reply_to_message_id=int(reply_to) if reply_to else None,\n-                        message_thread_id=self._message_thread_id_for_send(_audio_thread),\n+                    reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)\n+                    audio_thread_kwargs = self._thread_kwargs_for_send(\n+                        chat_id,\n+                        _audio_thread,\n+                        metadata,\n+                        reply_to_message_id=reply_to_id,\n+                    )\n+                    msg = await self._send_with_dm_topic_reply_anchor_retry(\n+                        self._bot.send_audio,\n+                        {\n+                            \"chat_id\": int(chat_id),\n+                            \"audio\": audio_file,\n+                            \"caption\": caption[:1024] if caption else None,\n+                            \"reply_to_message_id\": reply_to_id,\n+                            **audio_thread_kwargs,\n+                        },\n+                        metadata,\n+                        reply_to_id,\n+                        \"audio\",\n+                        reset_media=lambda: audio_file.seek(0),\n                     )\n                 else:\n                     # Formats Telegram can't play natively (.wav, .flac, ...)","@@ -2172,7 +2400,7 @@ async def send_voice(\n                 e,\n                 exc_info=True,\n             )\n-            return await super().send_voice(chat_id, audio_path, caption, reply_to)\n+            return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata)\n \n     async def send_multiple_images(\n         self,","@@ -2227,7 +2455,6 @@ async def send_multiple_images(\n \n         from urllib.parse import unquote as _unquote\n         _thread = self._metadata_thread_id(metadata)\n-        _thread_id = self._message_thread_id_for_send(_thread)\n \n         # Chunk into groups of 10 (Telegram's album limit)\n         CHUNK = 10","@@ -2263,10 +2490,33 @@ async def send_multiple_images(\n                     \"[%s] Sending media group of %d photo(s) (chunk %d/%d)\",\n                     self.name, len(media), chunk_idx + 1, len(chunks),\n                 )\n-                await self._bot.send_media_group(\n-                    chat_id=int(chat_id),\n-                    media=media,\n-                    message_thread_id=_thread_id,\n+                reply_to_id = self._reply_to_message_id_for_send(None, metadata)\n+                thread_kwargs = self._thread_kwargs_for_send(\n+                    chat_id,\n+                    _thread,\n+                    metadata,\n+                    reply_to_message_id=reply_to_id,\n+                )\n+\n+                def _reset_opened_files() -> None:\n+                    for fh in opened_files:\n+                        try:\n+                            fh.seek(0)\n+                        except Exception:\n+                            pass\n+\n+                await self._send_with_dm_topic_reply_anchor_retry(\n+                    self._bot.send_media_group,\n+                    {\n+                        \"chat_id\": int(chat_id),\n+                        \"media\": media,\n+                        \"reply_to_message_id\": reply_to_id,\n+                        **thread_kwargs,\n+                    },\n+                    metadata,\n+                    reply_to_id,\n+                    \"media group\",\n+                    reset_media=_reset_opened_files,\n                 )\n             except Exception as e:\n                 logger.warning(","@@ -2303,13 +2553,27 @@ async def send_image_file(\n                 return SendResult(success=False, error=self._missing_media_path_error(\"Image\", image_path))\n \n             _thread = self._metadata_thread_id(metadata)\n+            reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)\n+            thread_kwargs = self._thread_kwargs_for_send(\n+                chat_id,\n+                _thread,\n+                metadata,\n+                reply_to_message_id=reply_to_id,\n+            )\n             with open(image_path, \"rb\") as image_file:\n-                msg = await self._bot.send_photo(\n-                    chat_id=int(chat_id),\n-                    photo=image_file,\n-                    caption=caption[:1024] if caption else None,\n-                    reply_to_message_id=int(reply_to) if reply_to else None,\n-                    message_thread_id=self._message_thread_id_for_send(_thread),\n+                msg = await self._send_with_dm_topic_reply_anchor_retry(\n+                    self._bot.send_photo,\n+                    {\n+                        \"chat_id\": int(chat_id),\n+                        \"photo\": image_file,\n+                        \"caption\": caption[:1024] if caption else None,\n+                        \"reply_to_message_id\": reply_to_id,\n+                        **thread_kwargs,\n+                    },\n+                    metadata,\n+                    reply_to_id,\n+                    \"photo\",\n+                    reset_media=lambda: image_file.seek(0),\n                 )\n             return SendResult(success=True, message_id=str(msg.message_id))\n         except Exception as e:","@@ -2360,7 +2624,7 @@ async def send_image_file(\n                     doc_err,\n                     exc_info=True,\n                 )\n-                return await super().send_image_file(chat_id, image_path, caption, reply_to)\n+                return await super().send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata)\n \n     async def send_document(\n         self,","@@ -2382,20 +2646,34 @@ async def send_document(\n \n             display_name = file_name or os.path.basename(file_path)\n             _thread = self._metadata_thread_id(metadata)\n+            reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)\n+            thread_kwargs = self._thread_kwargs_for_send(\n+                chat_id,\n+                _thread,\n+                metadata,\n+                reply_to_message_id=reply_to_id,\n+            )\n \n             with open(file_path, \"rb\") as f:\n-                msg = await self._bot.send_document(\n-                    chat_id=int(chat_id),\n-                    document=f,\n-                    filename=display_name,\n-                    caption=caption[:1024] if caption else None,\n-                    reply_to_message_id=int(reply_to) if reply_to else None,\n-                    message_thread_id=self._message_thread_id_for_send(_thread),\n+                msg = await self._send_with_dm_topic_reply_anchor_retry(\n+                    self._bot.send_document,\n+                    {\n+                        \"chat_id\": int(chat_id),\n+                        \"document\": f,\n+                        \"filename\": display_name,\n+                        \"caption\": caption[:1024] if caption else None,\n+                        \"reply_to_message_id\": reply_to_id,\n+                        **thread_kwargs,\n+                    },\n+                    metadata,\n+                    reply_to_id,\n+                    \"document\",\n+                    reset_media=lambda: f.seek(0),\n                 )\n             return SendResult(success=True, message_id=str(msg.message_id))\n         except Exception as e:\n             print(f\"[{self.name}] Failed to send document: {e}\")\n-            return await super().send_document(chat_id, file_path, caption, file_name, reply_to)\n+            return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)\n \n     async def send_video(\n         self,","@@ -2415,18 +2693,32 @@ async def send_video(\n                 return SendResult(success=False, error=self._missing_media_path_error(\"Video\", video_path))\n \n             _thread = self._metadata_thread_id(metadata)\n+            reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)\n+            thread_kwargs = self._thread_kwargs_for_send(\n+                chat_id,\n+                _thread,\n+                metadata,\n+                reply_to_message_id=reply_to_id,\n+            )\n             with open(video_path, \"rb\") as f:\n-                msg = await self._bot.send_video(\n-                    chat_id=int(chat_id),\n-                    video=f,\n-                    caption=caption[:1024] if caption else None,\n-                    reply_to_message_id=int(reply_to) if reply_to else None,\n-                    message_thread_id=self._message_thread_id_for_send(_thread),\n+                msg = await self._send_with_dm_topic_reply_anchor_retry(\n+                    self._bot.send_video,\n+                    {\n+                        \"chat_id\": int(chat_id),\n+                        \"video\": f,\n+                        \"caption\": caption[:1024] if caption else None,\n+                        \"reply_to_message_id\": reply_to_id,\n+                        **thread_kwargs,\n+                    },\n+                    metadata,\n+                    reply_to_id,\n+                    \"video\",\n+                    reset_media=lambda: f.seek(0),\n                 )\n             return SendResult(success=True, message_id=str(msg.message_id))\n         except Exception as e:\n             print(f\"[{self.name}] Failed to send video: {e}\")\n-            return await super().send_video(chat_id, video_path, caption, reply_to)\n+            return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)\n \n     async def send_image(\n         self,","@@ -2452,12 +2744,25 @@ async def send_image(\n         try:\n             # Telegram can send photos directly from URLs (up to ~5MB)\n             _photo_thread = self._metadata_thread_id(metadata)\n-            msg = await self._bot.send_photo(\n-                chat_id=int(chat_id),\n-                photo=image_url,\n-                caption=caption[:1024] if caption else None,  # Telegram caption limit\n-                reply_to_message_id=int(reply_to) if reply_to else None,\n-                message_thread_id=self._message_thread_id_for_send(_photo_thread),\n+            reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)\n+            photo_thread_kwargs = self._thread_kwargs_for_send(\n+                chat_id,\n+                _photo_thread,\n+                metadata,\n+                reply_to_message_id=reply_to_id,\n+            )\n+            msg = await self._send_with_dm_topic_reply_anchor_retry(\n+                self._bot.send_photo,\n+                {\n+                    \"chat_id\": int(chat_id),\n+                    \"photo\": image_url,\n+                    \"caption\": caption[:1024] if caption else None,\n+                    \"reply_to_message_id\": reply_to_id,\n+                    **photo_thread_kwargs,\n+                },\n+                metadata,\n+                reply_to_id,\n+                \"URL photo\",\n             )\n             return SendResult(success=True, message_id=str(msg.message_id))\n         except Exception as e:","@@ -2474,13 +2779,25 @@ async def send_image(\n                     resp = await client.get(image_url)\n                     resp.raise_for_status()\n                     image_data = resp.content\n-                \n-                msg = await self._bot.send_photo(\n-                    chat_id=int(chat_id),\n-                    photo=image_data,\n-                    caption=caption[:1024] if caption else None,\n-                    reply_to_message_id=int(reply_to) if reply_to else None,\n-                    message_thread_id=self._message_thread_id_for_send(_photo_thread),\n+\n+                upload_thread_kwargs = self._thread_kwargs_for_send(\n+                    chat_id,\n+                    _photo_thread,\n+                    metadata,\n+                    reply_to_message_id=reply_to_id,\n+                )\n+                msg = await self._send_with_dm_topic_reply_anchor_retry(\n+                    self._bot.send_photo,\n+                    {\n+                        \"chat_id\": int(chat_id),\n+                        \"photo\": image_data,\n+                        \"caption\": caption[:1024] if caption else None,\n+                        \"reply_to_message_id\": reply_to_id,\n+                        **upload_thread_kwargs,\n+                    },\n+                    metadata,\n+                    reply_to_id,\n+                    \"uploaded photo\",\n                 )\n                 return SendResult(success=True, message_id=str(msg.message_id))\n             except Exception as e2:","@@ -2491,7 +2808,7 @@ async def send_image(\n                     exc_info=True,\n                 )\n                 # Final fallback: send URL as text\n-                return await super().send_image(chat_id, image_url, caption, reply_to)\n+                return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)\n \n     async def send_animation(\n         self,","@@ -2507,12 +2824,25 @@ async def send_animation(\n         \n         try:\n             _anim_thread = self._metadata_thread_id(metadata)\n-            msg = await self._bot.send_animation(\n-                chat_id=int(chat_id),\n-                animation=animation_url,\n-                caption=caption[:1024] if caption else None,\n-                reply_to_message_id=int(reply_to) if reply_to else None,\n-                message_thread_id=self._message_thread_id_for_send(_anim_thread),\n+            reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)\n+            animation_thread_kwargs = self._thread_kwargs_for_send(\n+                chat_id,\n+                _anim_thread,\n+                metadata,\n+                reply_to_message_id=reply_to_id,\n+            )\n+            msg = await self._send_with_dm_topic_reply_anchor_retry(\n+                self._bot.send_animation,\n+                {\n+                    \"chat_id\": int(chat_id),\n+                    \"animation\": animation_url,\n+                    \"caption\": caption[:1024] if caption else None,\n+                    \"reply_to_message_id\": reply_to_id,\n+                    **animation_thread_kwargs,\n+                },\n+                metadata,\n+                reply_to_id,\n+                \"animation\",\n             )\n             return SendResult(success=True, message_id=str(msg.message_id))\n         except Exception as e:","@@ -2523,13 +2853,21 @@ async def send_animation(\n                 exc_info=True,\n             )\n             # Fallback: try as a regular photo\n-            return await self.send_image(chat_id, animation_url, caption, reply_to)\n+            return await self.send_image(chat_id, animation_url, caption, reply_to, metadata=metadata)\n \n     async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:\n         \"\"\"Send typing indicator.\"\"\"\n         if self._bot:\n             try:\n                 _typing_thread = self._metadata_thread_id(metadata)\n+                # Skip the Bot API call entirely for Hermes-created DM topic\n+                # lanes: send_chat_action only accepts message_thread_id, which\n+                # Telegram's Bot API 10.0 rejects for these lanes. The send\n+                # path uses the reply-anchor fallback instead, but typing has\n+                # no equivalent — skipping avoids noisy \"thread not found\"\n+                # debug logs on every typing tick.\n+                if metadata and metadata.get(\"telegram_dm_topic_reply_fallback\"):\n+                    return\n                 message_thread_id = self._message_thread_id_for_typing(_typing_thread)\n                 # No retry-without-thread fallback here: _message_thread_id_for_typing\n                 # already maps the forum General topic to None, so any non-None value"]},{"file":"gateway/run.py","hunks":["@@ -571,6 +571,7 @@ def _reload_runtime_env_preserving_config_authority() -> None:\n     EphemeralReply,\n     MessageEvent,\n     MessageType,\n+    _reply_anchor_for_event,\n     merge_pending_message_event,\n )\n from gateway.restart import (","@@ -2406,7 +2407,8 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n             if not adapter:\n                 return True\n \n-            thread_meta = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n+            reply_anchor = self._reply_anchor_for_event(event)\n+            thread_meta = self._thread_metadata_for_source(event.source, reply_anchor)\n             if self._queue_during_drain_enabled():\n                 self._queue_or_replace_pending_event(session_key, event)\n                 message = f\"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back.\"","@@ -2416,7 +2418,13 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n             await adapter._send_with_retry(\n                 chat_id=event.source.chat_id,\n                 content=message,\n-                reply_to=event.message_id,\n+                reply_to=(\n+                    reply_anchor\n+                    if event.source.platform == Platform.TELEGRAM\n+                    and event.source.chat_type == \"dm\"\n+                    and event.source.thread_id\n+                    else (None if event.source.platform == Platform.TELEGRAM and event.source.thread_id else event.message_id)\n+                ),\n                 metadata=thread_meta,\n             )\n             return True","@@ -2553,12 +2561,19 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n         except Exception as _onb_err:\n             logger.debug(\"Failed to apply busy-input onboarding hint: %s\", _onb_err)\n \n-        thread_meta = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n+        reply_anchor = self._reply_anchor_for_event(event)\n+        thread_meta = self._thread_metadata_for_source(event.source, reply_anchor)\n         try:\n             await adapter._send_with_retry(\n                 chat_id=event.source.chat_id,\n                 content=message,\n-                reply_to=event.message_id,\n+                reply_to=(\n+                    reply_anchor\n+                    if event.source.platform == Platform.TELEGRAM\n+                    and event.source.chat_type == \"dm\"\n+                    and event.source.thread_id\n+                    else (None if event.source.platform == Platform.TELEGRAM and event.source.thread_id else event.message_id)\n+                ),\n                 metadata=thread_meta,\n             )\n         except Exception as e:","@@ -5063,7 +5078,7 @@ async def _deliver_platform_notice(self, source, content: str) -> None:\n         if config and hasattr(config, \"get_notice_delivery\"):\n             notice_delivery = config.get_notice_delivery(source.platform)\n \n-        metadata = {\"thread_id\": source.thread_id} if getattr(source, \"thread_id\", None) else None\n+        metadata = self._thread_metadata_for_source(source)\n         if notice_delivery == \"private\" and getattr(source, \"user_id\", None):\n             try:\n                 result = await adapter.send_private_notice(","@@ -6158,7 +6173,7 @@ async def _prepare_inbound_message_text(\n                 )\n                 if any(marker in message_text for marker in _stt_fail_markers):\n                     _stt_adapter = self.adapters.get(source.platform)\n-                    _stt_meta = {\"thread_id\": source.thread_id} if source.thread_id else None\n+                    _stt_meta = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))\n                     if _stt_adapter:\n                         try:\n                             _stt_msg = (","@@ -6679,7 +6694,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                         f\"{_compress_token_threshold:,}\",\n                     )\n \n-                    _hyg_meta = {\"thread_id\": source.thread_id} if source.thread_id else None\n+                    _hyg_meta = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))\n \n                     try:\n                         from run_agent import AIAgent","@@ -6908,7 +6923,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 session_id=session_entry.session_id,\n                 session_key=session_key,\n                 run_generation=run_generation,\n-                event_message_id=event.message_id,\n+                event_message_id=self._reply_anchor_for_event(event),\n                 channel_prompt=event.channel_prompt,\n             )\n ","@@ -7249,7 +7264,11 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                     try:\n                         _foot_adapter = self.adapters.get(source.platform)\n                         if _foot_adapter:\n-                            await _foot_adapter.send(source.chat_id, _footer_line)\n+                            await _foot_adapter.send(\n+                                source.chat_id,\n+                                _footer_line,\n+                                metadata=self._thread_metadata_for_source(source, self._reply_anchor_for_event(event)),\n+                            )\n                     except Exception as _e:\n                         logger.debug(\"trailing footer send failed: %s\", _e)\n                 return None","@@ -8264,7 +8283,7 @@ async def _on_model_selected(\n                         lines.append(\"_(session only — use `/model <name> --global` to persist)_\")\n                         return \"\\n\".join(lines)\n \n-                    metadata = {\"thread_id\": source.thread_id} if source.thread_id else None\n+                    metadata = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))\n                     result = await adapter.send_model_picker(\n                         chat_id=source.chat_id,\n                         providers=providers,","@@ -8685,7 +8704,7 @@ async def _send_goal_status_notice(self, source: Any, message: str) -> None:\n         try:\n             metadata = self._thread_metadata_for_source(source)\n         except Exception:\n-            metadata = {\"thread_id\": source.thread_id} if getattr(source, \"thread_id\", None) else None\n+            metadata = None\n \n         result = await adapter.send(source.chat_id, message, metadata=metadata)\n         if result is not None and not getattr(result, \"success\", True):","@@ -9250,13 +9269,15 @@ async def _send_voice_reply(self, event: MessageEvent, text: str) -> None:\n                     and adapter.is_in_voice_channel(guild_id)):\n                 await adapter.play_in_voice_channel(guild_id, actual_path)\n             elif adapter and hasattr(adapter, \"send_voice\"):\n+                reply_anchor = self._reply_anchor_for_event(event)\n+                thread_meta = self._thread_metadata_for_source(event.source, reply_anchor)\n                 send_kwargs: Dict[str, Any] = {\n                     \"chat_id\": event.source.chat_id,\n                     \"audio_path\": actual_path,\n-                    \"reply_to\": event.message_id,\n+                    \"reply_to\": reply_anchor,\n                 }\n-                if event.source.thread_id:\n-                    send_kwargs[\"metadata\"] = {\"thread_id\": event.source.thread_id}\n+                if thread_meta:\n+                    send_kwargs[\"metadata\"] = thread_meta\n                 await adapter.send_voice(**send_kwargs)\n         except Exception as e:\n             logger.warning(\"Auto voice reply failed: %s\", e, exc_info=True)","@@ -9293,7 +9314,7 @@ async def _deliver_media_from_response(\n             _, cleaned = adapter.extract_images(response)\n             local_files, _ = adapter.extract_local_files(cleaned)\n \n-            _thread_meta = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n+            _thread_meta = self._thread_metadata_for_source(event.source, self._reply_anchor_for_event(event))\n \n             from gateway.platforms.base import should_send_media_as_audio\n ","@@ -9457,9 +9478,16 @@ async def _handle_background_command(self, event: MessageEvent) -> str:\n         source = event.source\n         task_id = f\"bg_{datetime.now().strftime('%H%M%S')}_{os.urandom(3).hex()}\"\n \n+        event_message_id = self._reply_anchor_for_event(event)\n+\n         # Fire-and-forget the background task\n         _task = asyncio.create_task(\n-            self._run_background_task(prompt, source, task_id)\n+            self._run_background_task(\n+                prompt,\n+                source,\n+                task_id,\n+                event_message_id=event_message_id,\n+            )\n         )\n         self._background_tasks.add(_task)\n         _task.add_done_callback(self._background_tasks.discard)","@@ -9468,7 +9496,11 @@ async def _handle_background_command(self, event: MessageEvent) -> str:\n         return f'🔄 Background task started: \"{preview}\"\\nTask ID: {task_id}\\nYou can keep chatting — results will appear when done.'\n \n     async def _run_background_task(\n-        self, prompt: str, source: \"SessionSource\", task_id: str\n+        self,\n+        prompt: str,\n+        source: \"SessionSource\",\n+        task_id: str,\n+        event_message_id: Optional[str] = None,\n     ) -> None:\n         \"\"\"Execute a background agent task and deliver the result to the chat.\"\"\"\n         from run_agent import AIAgent","@@ -9478,7 +9510,7 @@ async def _run_background_task(\n             logger.warning(\"No adapter for platform %s in background task %s\", source.platform, task_id)\n             return\n \n-        _thread_metadata = {\"thread_id\": source.thread_id} if source.thread_id else None\n+        _thread_metadata = self._thread_metadata_for_source(source, event_message_id)\n \n         try:\n             user_config = _load_gateway_config()","@@ -11293,7 +11325,7 @@ async def _request_slash_confirm(\n         _slash_confirm_mod.register(session_key, confirm_id, command, handler)\n \n         adapter = self.adapters.get(source.platform)\n-        metadata = self._thread_metadata_for_source(source)\n+        metadata = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))\n \n         used_buttons = False\n         if adapter is not None:","@@ -11333,12 +11365,30 @@ def _read_user_config(self) -> Dict[str, Any]:\n         except Exception:\n             return {}\n \n-    def _thread_metadata_for_source(self, source) -> Optional[Dict[str, Any]]:\n+    def _thread_metadata_for_source(\n+        self,\n+        source,\n+        reply_to_message_id: Optional[str] = None,\n+    ) -> Optional[Dict[str, Any]]:\n         \"\"\"Build the metadata dict platforms need for thread-aware replies.\"\"\"\n         thread_id = getattr(source, \"thread_id\", None)\n         if thread_id is None:\n             return None\n-        return {\"thread_id\": thread_id}\n+        metadata: Dict[str, Any] = {\"thread_id\": thread_id}\n+        if (\n+            getattr(source, \"platform\", None) == Platform.TELEGRAM\n+            and getattr(source, \"chat_type\", None) == \"dm\"\n+        ):\n+            metadata[\"telegram_dm_topic_reply_fallback\"] = True\n+            anchor = reply_to_message_id or getattr(source, \"message_id\", None)\n+            if anchor is not None:\n+                metadata[\"telegram_reply_to_message_id\"] = str(anchor)\n+        return metadata\n+\n+    @staticmethod\n+    def _reply_anchor_for_event(event: MessageEvent) -> Optional[str]:\n+        \"\"\"Return the platform-specific reply anchor for GatewayRunner sends.\"\"\"\n+        return _reply_anchor_for_event(event)\n \n \n     # ------------------------------------------------------------------","@@ -13131,10 +13181,7 @@ def _run_still_current() -> bool:\n             else bool(_plat_streaming)\n         )\n \n-        if source.thread_id:\n-            _thread_metadata: Optional[Dict[str, Any]] = {\"thread_id\": source.thread_id}\n-        else:\n-            _thread_metadata = None\n+        _thread_metadata: Optional[Dict[str, Any]] = self._thread_metadata_for_source(source, event_message_id)\n \n         if _streaming_enabled:\n             try:","@@ -13564,8 +13611,8 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non\n         #\n         # Threading metadata is platform-specific:\n         # - Slack DM threading needs event_message_id fallback (reply thread)\n-        # - Telegram uses message_thread_id only for forum topics; passing a\n-        #   normal DM/group message id as thread_id causes send failures\n+        # - Telegram forum topics use message_thread_id; Hermes-created private\n+        #   DM topic lanes require both thread metadata and a reply anchor\n         # - Feishu only honors reply_in_thread when sending a reply, so topic\n         #   progress uses the triggering event message as the reply target\n         # - Other platforms should use explicit source.thread_id only","@@ -13573,7 +13620,11 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non\n             _progress_thread_id = source.thread_id or event_message_id\n         else:\n             _progress_thread_id = source.thread_id\n-        _progress_metadata = {\"thread_id\": _progress_thread_id} if _progress_thread_id else None\n+        _progress_metadata = (\n+            self._thread_metadata_for_source(source, event_message_id)\n+            if _progress_thread_id == source.thread_id\n+            else {\"thread_id\": _progress_thread_id}\n+        ) if _progress_thread_id else None\n         _progress_reply_to = (\n             event_message_id\n             if source.platform == Platform.FEISHU and source.thread_id and event_message_id","@@ -13833,7 +13884,7 @@ def _step_callback_sync(iteration: int, prev_tools: list) -> None:\n                 \"reply_to_message_id\": event_message_id,\n             }\n         else:\n-            _status_thread_metadata = {\"thread_id\": _progress_thread_id} if _progress_thread_id else None\n+            _status_thread_metadata = self._thread_metadata_for_source(source, event_message_id) if _progress_thread_id else None\n \n         def _status_callback_sync(event_type: str, message: str) -> None:\n             if not _status_adapter or not _run_still_current():","@@ -15118,7 +15169,7 @@ async def _notify_long_running():\n                     )\n                     if next_message is None:\n                         return result\n-                    next_message_id = getattr(pending_event, \"message_id\", None)\n+                    next_message_id = self._reply_anchor_for_event(pending_event)\n                     next_channel_prompt = getattr(pending_event, \"channel_prompt\", None)\n \n                 # Restart typing indicator so the user sees activity while"]},{"file":"tests/gateway/test_background_command.py","hunks":["@@ -108,6 +108,38 @@ def capture_task(coro, *args, **kwargs):\n         assert \"Summarize the top HN stories\" in result\n         assert len(created_tasks) == 1  # background task was created\n \n+    @pytest.mark.asyncio\n+    async def test_telegram_dm_topic_passes_trigger_anchor_to_task(self):\n+        \"\"\"Telegram private-topic completion sends need the original command message id.\"\"\"\n+        runner = _make_runner()\n+        runner._run_background_task = AsyncMock()\n+\n+        def capture_task(coro, *args, **kwargs):\n+            coro.close()\n+            mock_task = MagicMock()\n+            return mock_task\n+\n+        source = SessionSource(\n+            platform=Platform.TELEGRAM,\n+            user_id=\"12345\",\n+            chat_id=\"67890\",\n+            chat_type=\"dm\",\n+            thread_id=\"20197\",\n+        )\n+        event = MessageEvent(\n+            text=\"/background summarize\",\n+            source=source,\n+            message_id=\"463\",\n+            reply_to_message_id=\"462\",\n+        )\n+\n+        with patch(\"gateway.run.asyncio.create_task\", side_effect=capture_task):\n+            result = await runner._handle_background_command(event)\n+\n+        assert \"Background task started\" in result\n+        runner._run_background_task.assert_called_once()\n+        assert runner._run_background_task.call_args.kwargs[\"event_message_id\"] == \"463\"\n+\n     @pytest.mark.asyncio\n     async def test_prompt_truncated_in_preview(self):\n         \"\"\"Long prompts are truncated to 60 chars in the confirmation message.\"\"\"","@@ -236,6 +268,57 @@ async def test_successful_task_sends_result(self):\n         mock_agent_instance.shutdown_memory_provider.assert_called_once()\n         mock_agent_instance.close.assert_called_once()\n \n+    @pytest.mark.asyncio\n+    async def test_telegram_dm_topic_completion_preserves_reply_anchor_metadata(self, monkeypatch):\n+        \"\"\"Background completion metadata must let Telegram send thread id plus reply id.\"\"\"\n+        from gateway import run as gateway_run\n+\n+        runner = _make_runner()\n+        runner._resolve_session_agent_runtime = MagicMock(\n+            return_value=(\"test-model\", {\"api_key\": \"test-key\"})\n+        )\n+        runner._resolve_session_reasoning_config = MagicMock(return_value=None)\n+        runner._load_service_tier = MagicMock(return_value=None)\n+        runner._resolve_turn_agent_config = MagicMock(\n+            return_value={\n+                \"model\": \"test-model\",\n+                \"runtime\": {\"api_key\": \"test-key\"},\n+                \"request_overrides\": None,\n+            }\n+        )\n+        runner._run_in_executor_with_context = AsyncMock(\n+            return_value={\"final_response\": \"done\", \"messages\": []}\n+        )\n+        monkeypatch.setattr(gateway_run, \"_load_gateway_config\", lambda: {})\n+\n+        mock_adapter = AsyncMock()\n+        mock_adapter.send = AsyncMock()\n+        mock_adapter.extract_media = MagicMock(return_value=([], \"done\"))\n+        mock_adapter.extract_images = MagicMock(return_value=([], \"done\"))\n+        runner.adapters[Platform.TELEGRAM] = mock_adapter\n+\n+        source = SessionSource(\n+            platform=Platform.TELEGRAM,\n+            user_id=\"12345\",\n+            chat_id=\"67890\",\n+            chat_type=\"dm\",\n+            thread_id=\"20197\",\n+        )\n+\n+        await runner._run_background_task(\n+            \"say hello\",\n+            source,\n+            \"bg_test\",\n+            event_message_id=\"463\",\n+        )\n+\n+        mock_adapter.send.assert_called_once()\n+        assert mock_adapter.send.call_args.kwargs[\"metadata\"] == {\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"463\",\n+        }\n+\n     @pytest.mark.asyncio\n     async def test_agent_cleanup_runs_when_background_agent_raises(self):\n         \"\"\"Temporary background agents must be cleaned up on error paths too.\"\"\""]},{"file":"tests/gateway/test_telegram_thread_fallback.py","hunks":["@@ -1,13 +1,11 @@\n-\"\"\"Tests for Telegram send() thread_id fallback.\n-\n-When message_thread_id points to a non-existent thread, Telegram returns\n-BadRequest('Message thread not found'). Since BadRequest is a subclass of\n-NetworkError in python-telegram-bot, the old retry loop treated this as a\n-transient error and retried 3 times before silently failing — killing all\n-tool progress messages, streaming responses, and typing indicators.\n-\n-The fix detects \"thread not found\" BadRequest errors and retries the send\n-WITHOUT message_thread_id so the message still reaches the chat.\n+\"\"\"Tests for Telegram topic/thread routing fallbacks.\n+\n+Supergroup forum topics route with ``message_thread_id``. Hermes-created\n+private DM topic lanes are different: live Telegram testing showed they only\n+stay in the expected lane when sends include both the private topic\n+``message_thread_id`` and a ``reply_to_message_id`` anchor to the triggering\n+user message. If either anchor is unavailable or rejected, the adapter must\n+avoid retrying with a partial topic route that can render outside the lane.\n \"\"\"\n \n import sys","@@ -17,7 +15,14 @@\n import pytest\n \n from gateway.config import PlatformConfig, Platform\n-from gateway.platforms.base import SendResult\n+from gateway.platforms.base import (\n+    MessageEvent,\n+    MessageType,\n+    SendResult,\n+    _reply_anchor_for_event,\n+    _thread_metadata_for_source,\n+)\n+from gateway.session import build_session_key\n \n \n # ── Fake telegram.error hierarchy ──────────────────────────────────────","@@ -44,23 +49,48 @@ def __init__(self, seconds):\n \n \n # Build a fake telegram module tree so the adapter's internal imports work\n+class _FakeInlineKeyboardButton:\n+    def __init__(self, text, callback_data=None, **kwargs):\n+        self.text = text\n+        self.callback_data = callback_data\n+        self.kwargs = kwargs\n+\n+\n+class _FakeInlineKeyboardMarkup:\n+    def __init__(self, inline_keyboard):\n+        self.inline_keyboard = inline_keyboard\n+\n+\n+class _FakeInputMediaPhoto:\n+    def __init__(self, media, caption=None, **kwargs):\n+        self.media = media\n+        self.caption = caption\n+        self.kwargs = kwargs\n+\n+\n _fake_telegram = types.ModuleType(\"telegram\")\n _fake_telegram.Update = object\n _fake_telegram.Bot = object\n _fake_telegram.Message = object\n-_fake_telegram.InlineKeyboardButton = object\n-_fake_telegram.InlineKeyboardMarkup = object\n+_fake_telegram.InlineKeyboardButton = _FakeInlineKeyboardButton\n+_fake_telegram.InlineKeyboardMarkup = _FakeInlineKeyboardMarkup\n+_fake_telegram.InputMediaPhoto = _FakeInputMediaPhoto\n _fake_telegram_error = types.ModuleType(\"telegram.error\")\n _fake_telegram_error.NetworkError = FakeNetworkError\n _fake_telegram_error.BadRequest = FakeBadRequest\n _fake_telegram_error.TimedOut = FakeTimedOut\n _fake_telegram.error = _fake_telegram_error\n _fake_telegram_constants = types.ModuleType(\"telegram.constants\")\n-_fake_telegram_constants.ParseMode = SimpleNamespace(MARKDOWN_V2=\"MarkdownV2\")\n+_fake_telegram_constants.ParseMode = SimpleNamespace(\n+    MARKDOWN_V2=\"MarkdownV2\",\n+    MARKDOWN=\"Markdown\",\n+    HTML=\"HTML\",\n+)\n _fake_telegram_constants.ChatType = SimpleNamespace(\n     GROUP=\"group\",\n     SUPERGROUP=\"supergroup\",\n     CHANNEL=\"channel\",\n+    PRIVATE=\"private\",\n )\n _fake_telegram.constants = _fake_telegram_constants\n _fake_telegram_ext = types.ModuleType(\"telegram.ext\")","@@ -205,6 +235,36 @@ async def mock_send_chat_action(**kwargs):\n     ]\n \n \n[redacted:email]\n+async def test_send_typing_skips_api_call_for_dm_topic_reply_fallback():\n+    \"\"\"Hermes-created DM topic lanes have no working Bot API typing route.\n+\n+    ``send_chat_action`` only accepts ``message_thread_id``, which Telegram's\n+    Bot API 10.0 rejects for these lanes — the call would silently fail and\n+    log a \"thread not found\" warning every typing tick (every 2s). Skipping\n+    the call entirely keeps logs clean while preserving the user-visible\n+    behavior (no typing indicator either way for these lanes).\n+    \"\"\"\n+    adapter = _make_adapter()\n+    call_log = []\n+\n+    async def mock_send_chat_action(**kwargs):\n+        call_log.append(dict(kwargs))\n+\n+    adapter._bot = SimpleNamespace(send_chat_action=mock_send_chat_action)\n+\n+    await adapter.send_typing(\n+        \"12345\",\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"462\",\n+        },\n+    )\n+\n+    assert call_log == []\n+\n+\n @pytest.mark.asyncio\n async def test_send_retries_without_thread_on_thread_not_found():\n     \"\"\"When message_thread_id causes 'thread not found', retry without it.\"\"\"","@@ -235,6 +295,626 @@ async def mock_send_message(**kwargs):\n     assert call_log[1][\"message_thread_id\"] is None\n \n \n[redacted:email]\n+async def test_send_private_dm_topic_uses_direct_messages_topic_id():\n+    \"\"\"Private Telegram topics route sends via direct_messages_topic_id.\"\"\"\n+    adapter = _make_adapter()\n+    call_log = []\n+\n+    async def mock_send_message(**kwargs):\n+        call_log.append(dict(kwargs))\n+        return SimpleNamespace(message_id=42)\n+\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    result = await adapter.send(\n+        chat_id=\"123\",\n+        content=\"test message\",\n+        metadata={\"thread_id\": \"99999\", \"direct_messages_topic_id\": \"99999\"},\n+    )\n+\n+    assert result.success is True\n+    assert call_log[0][\"message_thread_id\"] is None\n+    assert call_log[0][\"direct_messages_topic_id\"] == 99999\n+\n+\n+def test_base_gateway_metadata_marks_telegram_dm_topics_as_reply_fallback():\n+    source = SimpleNamespace(\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+        thread_id=\"20189\",\n+    )\n+\n+    metadata = _thread_metadata_for_source(source, \"462\")\n+\n+    assert metadata == {\n+        \"thread_id\": \"20189\",\n+        \"telegram_dm_topic_reply_fallback\": True,\n+        \"telegram_reply_to_message_id\": \"462\",\n+    }\n+\n+\n+def test_base_gateway_replies_to_triggering_message_for_telegram_dm_topic():\n+    \"\"\"Private DM topic lanes should anchor replies to the active user message.\"\"\"\n+    event = SimpleNamespace(\n+        message_id=\"463\",\n+        reply_to_message_id=\"462\",\n+        source=SimpleNamespace(\n+            platform=Platform.TELEGRAM,\n+            chat_type=\"dm\",\n+            thread_id=\"20189\",\n+        ),\n+    )\n+\n+    assert _reply_anchor_for_event(event) == \"463\"\n+\n+\n[redacted:email]\n+async def test_gateway_runner_busy_ack_replies_to_triggering_message_for_telegram_dm_topic(monkeypatch, tmp_path):\n+    \"\"\"GatewayRunner's duplicate thread metadata must match the base helper.\"\"\"\n+    from gateway import run as gateway_run\n+\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    GatewayRunner = gateway_run.GatewayRunner\n+\n+    class BusyAdapter:\n+        def __init__(self):\n+            self._pending_messages = {}\n+            self.calls = []\n+\n+        async def _send_with_retry(self, **kwargs):\n+            self.calls.append(kwargs)\n+            return SendResult(success=True, message_id=\"ack-1\")\n+\n+    class BusyAgent:\n+        def interrupt(self, _text):\n+            return None\n+\n+        def get_activity_summary(self):\n+            return {}\n+\n+    source = SimpleNamespace(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"12345\",\n+        chat_type=\"dm\",\n+        thread_id=\"20197\",\n+        user_id=\"user-1\",\n+    )\n+    event = MessageEvent(\n+        text=\"busy follow-up\",\n+        message_type=MessageType.TEXT,\n+        source=source,\n+        message_id=\"463\",\n+        reply_to_message_id=\"462\",\n+    )\n+    session_key = build_session_key(source)\n+    adapter = BusyAdapter()\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner.adapters = {Platform.TELEGRAM: adapter}\n+    runner._running_agents = {session_key: BusyAgent()}\n+    runner._running_agents_ts = {}\n+    runner._pending_messages = {}\n+    runner._busy_ack_ts = {}\n+    runner._draining = False\n+    runner._busy_input_mode = \"interrupt\"\n+    runner._is_user_authorized = lambda _source: True\n+\n+    assert await runner._handle_active_session_busy_message(event, session_key) is True\n+\n+    assert adapter.calls\n+    assert adapter.calls[0][\"reply_to\"] == \"463\"\n+    assert adapter.calls[0][\"metadata\"] == {\n+        \"thread_id\": \"20197\",\n+        \"telegram_dm_topic_reply_fallback\": True,\n+        \"telegram_reply_to_message_id\": \"463\",\n+    }\n+\n+\n[redacted:email]\n+async def test_send_uses_reply_fallback_for_hermes_dm_topics():\n+    \"\"\"Hermes-created Telegram DM topics route with thread id plus reply anchor.\"\"\"\n+    adapter = _make_adapter()\n+    call_log = []\n+\n+    async def mock_send_message(**kwargs):\n+        call_log.append(kwargs)\n+        return SimpleNamespace(message_id=777)\n+\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    result = await adapter.send(\n+        chat_id=\"123\",\n+        content=\"test message\",\n+        reply_to=\"462\",\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+        },\n+    )\n+\n+    assert result.success is True\n+    assert call_log[0][\"reply_to_message_id\"] == 462\n+    assert call_log[0][\"message_thread_id\"] == 20197\n+    assert \"direct_messages_topic_id\" not in call_log[0]\n+\n+\n[redacted:email]\n+async def test_send_uses_metadata_reply_fallback_for_streaming_dm_topics():\n+    \"\"\"Metadata-only sends still stay in Hermes-created Telegram DM topics.\"\"\"\n+    adapter = _make_adapter()\n+    call_log = []\n+\n+    async def mock_send_message(**kwargs):\n+        call_log.append(kwargs)\n+        return SimpleNamespace(message_id=778)\n+\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    result = await adapter.send(\n+        chat_id=\"123\",\n+        content=\"streamed text\",\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"462\",\n+        },\n+    )\n+\n+    assert result.success is True\n+    assert call_log[0][\"reply_to_message_id\"] == 462\n+    assert call_log[0][\"message_thread_id\"] == 20197\n+    assert \"direct_messages_topic_id\" not in call_log[0]\n+\n+\n[redacted:email]\n+async def test_send_reply_fallback_applies_to_every_chunk_for_dm_topics():\n+    \"\"\"Long Telegram DM-topic fallback sends must anchor every chunk.\"\"\"\n+    adapter = _make_adapter()\n+    call_log = []\n+\n+    async def mock_send_message(**kwargs):\n+        call_log.append(dict(kwargs))\n+        return SimpleNamespace(message_id=len(call_log))\n+\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    result = await adapter.send(\n+        chat_id=\"123\",\n+        content=\"A\" * 5000,\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"462\",\n+        },\n+    )\n+\n+    assert result.success is True\n+    assert len(call_log) > 1\n+    assert all(call[\"reply_to_message_id\"] == 462 for call in call_log)\n+    assert all(call[\"message_thread_id\"] == 20197 for call in call_log)\n+    assert all(\"direct_messages_topic_id\" not in call for call in call_log)\n+\n+\n[redacted:email]\n+async def test_send_model_picker_uses_metadata_reply_fallback_for_dm_topics():\n+    \"\"\"Inline keyboard sends also consume the metadata reply fallback.\"\"\"\n+    adapter = _make_adapter()\n+    adapter._model_picker_state = {}\n+    call_log = []\n+\n+    async def mock_send_message(**kwargs):\n+        call_log.append(kwargs)\n+        return SimpleNamespace(message_id=779)\n+\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    result = await adapter.send_model_picker(\n+        chat_id=\"123\",\n+        providers=[{\"name\": \"OpenAI\", \"slug\": \"openai\", \"models\": [], \"total_models\": 0}],\n+        current_model=\"gpt-test\",\n+        current_provider=\"openai\",\n+        session_key=\"telegram:123:20197\",\n+        on_model_selected=lambda *_: None,\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"462\",\n+        },\n+    )\n+\n+    assert result.success is True\n+    assert call_log[0][\"reply_to_message_id\"] == 462\n+    assert call_log[0][\"message_thread_id\"] == 20197\n+    assert \"direct_messages_topic_id\" not in call_log[0]\n+\n+\n[redacted:email]\n+async def test_send_dm_topic_fallback_without_anchor_does_not_crash():\n+    \"\"\"DM-topic fallback without an anchor must not use message_thread_id alone.\"\"\"\n+    adapter = _make_adapter()\n+    call_log = []\n+\n+    async def mock_send_message(**kwargs):\n+        call_log.append(dict(kwargs))\n+        return SimpleNamespace(message_id=780)\n+\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    result = await adapter.send(\n+        chat_id=\"123\",\n+        content=\"source-only send\",\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+        },\n+    )\n+\n+    assert result.success is True\n+    assert call_log[0][\"reply_to_message_id\"] is None\n+    assert \"message_thread_id\" not in call_log[0]\n+    assert \"direct_messages_topic_id\" not in call_log[0]\n+\n+\n[redacted:email]\n+async def test_send_dm_topic_reply_not_found_retry_drops_thread_id():\n+    \"\"\"If Telegram deletes the reply anchor, private-topic retry must drop thread id too.\"\"\"\n+    adapter = _make_adapter()\n+    call_log = []\n+\n+    async def mock_send_message(**kwargs):\n+        call_log.append(dict(kwargs))\n+        if len(call_log) == 1:\n+            raise FakeBadRequest(\"Message to be replied not found\")\n+        return SimpleNamespace(message_id=781)\n+\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    result = await adapter.send(\n+        chat_id=\"123\",\n+        content=\"anchor disappeared\",\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"462\",\n+        },\n+    )\n+\n+    assert result.success is True\n+    assert call_log[0][\"reply_to_message_id\"] == 462\n+    assert call_log[0][\"message_thread_id\"] == 20197\n+    assert call_log[1][\"reply_to_message_id\"] is None\n+    assert \"message_thread_id\" not in call_log[1]\n+    assert \"direct_messages_topic_id\" not in call_log[1]\n+\n+\n[redacted:email]\n[redacted:email](\n+    (\"method_name\", \"bot_method_name\", \"path_kw\", \"filename\", \"payload\"),\n+    [\n+        (\"send_image_file\", \"send_photo\", \"image_path\", \"photo.png\", b\"png-data\"),\n+        (\"send_document\", \"send_document\", \"file_path\", \"report.txt\", b\"report-data\"),\n+        (\"send_video\", \"send_video\", \"video_path\", \"clip.mp4\", b\"video-data\"),\n+        (\"send_voice\", \"send_voice\", \"audio_path\", \"clip.ogg\", b\"ogg-data\"),\n+        (\"send_voice\", \"send_audio\", \"audio_path\", \"clip.mp3\", b\"mp3-data\"),\n+    ],\n+)\n+async def test_native_media_dm_topic_reply_not_found_retry_drops_thread_id(\n+    tmp_path,\n+    method_name,\n+    bot_method_name,\n+    path_kw,\n+    filename,\n+    payload,\n+):\n+    adapter = _make_adapter()\n+    media_path = tmp_path / filename\n+    media_path.write_bytes(payload)\n+    call_log = []\n+\n+    async def mock_send_media(**kwargs):\n+        call_log.append(dict(kwargs))\n+        if len(call_log) == 1:\n+            raise FakeBadRequest(\"Message to be replied not found\")\n+        return SimpleNamespace(message_id=782)\n+\n+    adapter._bot = SimpleNamespace(**{bot_method_name: mock_send_media})\n+\n+    result = await getattr(adapter, method_name)(\n+        chat_id=\"123\",\n+        **{path_kw: str(media_path)},\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"462\",\n+        },\n+    )\n+\n+    assert result.success is True\n+    assert call_log[0][\"reply_to_message_id\"] == 462\n+    assert call_log[0][\"message_thread_id\"] == 20197\n+    assert call_log[1][\"reply_to_message_id\"] is None\n+    assert \"message_thread_id\" not in call_log[1]\n+    assert \"direct_messages_topic_id\" not in call_log[1]\n+\n+\n[redacted:email]\n+async def test_animation_dm_topic_reply_not_found_retry_drops_thread_id():\n+    adapter = _make_adapter()\n+    call_log = []\n+\n+    async def mock_send_animation(**kwargs):\n+        call_log.append(dict(kwargs))\n+        if len(call_log) == 1:\n+            raise FakeBadRequest(\"Message to be replied not found\")\n+        return SimpleNamespace(message_id=786)\n+\n+    adapter._bot = SimpleNamespace(send_animation=mock_send_animation)\n+\n+    result = await adapter.send_animation(\n+        chat_id=\"123\",\n+        animation_url=\"https://example.com/anim.gif\",\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"462\",\n+        },\n+    )\n+\n+    assert result.success is True\n+    assert call_log[0][\"reply_to_message_id\"] == 462\n+    assert call_log[0][\"message_thread_id\"] == 20197\n+    assert call_log[1][\"reply_to_message_id\"] is None\n+    assert \"message_thread_id\" not in call_log[1]\n+    assert \"direct_messages_topic_id\" not in call_log[1]\n+\n+\n[redacted:email]\n+async def test_media_group_dm_topic_reply_not_found_retry_drops_thread_id(tmp_path):\n+    adapter = _make_adapter()\n+    image_path = tmp_path / \"photo.png\"\n+    image_path.write_bytes(b\"png-data\")\n+    call_log = []\n+\n+    async def mock_send_media_group(**kwargs):\n+        call_log.append(dict(kwargs))\n+        if len(call_log) == 1:\n+            raise FakeBadRequest(\"Message to be replied not found\")\n+        return [SimpleNamespace(message_id=783)]\n+\n+    adapter._bot = SimpleNamespace(send_media_group=mock_send_media_group)\n+\n+    await adapter.send_multiple_images(\n+        chat_id=\"123\",\n+        images=[(f\"file://{image_path}\", \"caption\")],\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"462\",\n+        },\n+    )\n+\n+    assert call_log[0][\"reply_to_message_id\"] == 462\n+    assert call_log[0][\"message_thread_id\"] == 20197\n+    assert call_log[1][\"reply_to_message_id\"] is None\n+    assert \"message_thread_id\" not in call_log[1]\n+    assert \"direct_messages_topic_id\" not in call_log[1]\n+\n+\n[redacted:email]\n+async def test_send_image_url_dm_topic_reply_not_found_retry_drops_thread_id(monkeypatch):\n+    adapter = _make_adapter()\n+    call_log = []\n+\n+    async def mock_send_photo(**kwargs):\n+        call_log.append(dict(kwargs))\n+        if len(call_log) == 1:\n+            raise FakeBadRequest(\"Message to be replied not found\")\n+        return SimpleNamespace(message_id=784)\n+\n+    adapter._bot = SimpleNamespace(send_photo=mock_send_photo)\n+    import tools.url_safety as url_safety\n+\n+    monkeypatch.setattr(url_safety, \"is_safe_url\", lambda _url: True)\n+\n+    result = await adapter.send_image(\n+        chat_id=\"123\",\n+        image_url=\"https://example.com/photo.png\",\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"462\",\n+        },\n+    )\n+\n+    assert result.success is True\n+    assert call_log[0][\"reply_to_message_id\"] == 462\n+    assert call_log[0][\"message_thread_id\"] == 20197\n+    assert call_log[1][\"reply_to_message_id\"] is None\n+    assert \"message_thread_id\" not in call_log[1]\n+    assert \"direct_messages_topic_id\" not in call_log[1]\n+\n+\n[redacted:email]\n+async def test_send_image_upload_dm_topic_reply_not_found_retry_drops_thread_id(monkeypatch):\n+    adapter = _make_adapter()\n+    call_log = []\n+\n+    async def mock_send_photo(**kwargs):\n+        call_log.append(dict(kwargs))\n+        if len(call_log) == 1:\n+            raise RuntimeError(\"URL is too large\")\n+        if len(call_log) == 2:\n+            raise FakeBadRequest(\"Message to be replied not found\")\n+        return SimpleNamespace(message_id=785)\n+\n+    class _FakeResponse:\n+        content = b\"image-data\"\n+\n+        def raise_for_status(self):\n+            return None\n+\n+    class _FakeAsyncClient:\n+        def __init__(self, *args, **kwargs):\n+            pass\n+\n+        async def __aenter__(self):\n+            return self\n+\n+        async def __aexit__(self, *args):\n+            return None\n+\n+        async def get(self, _url):\n+            return _FakeResponse()\n+\n+    monkeypatch.setitem(\n+        sys.modules,\n+        \"httpx\",\n+        SimpleNamespace(AsyncClient=_FakeAsyncClient),\n+    )\n+    adapter._bot = SimpleNamespace(send_photo=mock_send_photo)\n+    import tools.url_safety as url_safety\n+\n+    monkeypatch.setattr(url_safety, \"is_safe_url\", lambda _url: True)\n+\n+    result = await adapter.send_image(\n+        chat_id=\"123\",\n+        image_url=\"https://example.com/photo.png\",\n+        metadata={\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"462\",\n+        },\n+    )\n+\n+    assert result.success is True\n+    assert call_log[0][\"reply_to_message_id\"] == 462\n+    assert call_log[0][\"message_thread_id\"] == 20197\n+    assert call_log[1][\"reply_to_message_id\"] == 462\n+    assert call_log[1][\"message_thread_id\"] == 20197\n+    assert call_log[2][\"reply_to_message_id\"] is None\n+    assert \"message_thread_id\" not in call_log[2]\n+    assert \"direct_messages_topic_id\" not in call_log[2]\n+\n+\n[redacted:email]\n+async def test_slash_confirm_private_topic_callback_followup_sends_thread_and_reply(monkeypatch):\n+    adapter = _make_adapter()\n+    adapter._slash_confirm_state = {\"confirm-1\": \"session-1\"}\n+    adapter._is_callback_user_authorized = lambda *args, **kwargs: True\n+    call_log = []\n+\n+    async def mock_send_message(**kwargs):\n+        call_log.append(dict(kwargs))\n+        return SimpleNamespace(message_id=9001)\n+\n+    async def resolve(_session_key, _confirm_id, _choice):\n+        return \"done\"\n+\n+    from tools import slash_confirm\n+\n+    monkeypatch.setattr(slash_confirm, \"resolve\", resolve)\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    class Query:\n+        data = \"sc:once:confirm-1\"\n+        from_user = SimpleNamespace(id=42, first_name=\"Alice\")\n+        message = SimpleNamespace(\n+            chat_id=12345,\n+            chat=SimpleNamespace(type=_fake_telegram_constants.ChatType.PRIVATE),\n+            message_thread_id=20197,\n+            message_id=462,\n+        )\n+\n+        async def answer(self, **kwargs):\n+            return None\n+\n+        async def edit_message_text(self, **kwargs):\n+            return None\n+\n+    await adapter._handle_callback_query(SimpleNamespace(callback_query=Query()), SimpleNamespace())\n+\n+    assert call_log\n+    assert call_log[0][\"message_thread_id\"] == 20197\n+    assert call_log[0][\"reply_to_message_id\"] == 462\n+\n+\n[redacted:email]\n+async def test_slash_confirm_forum_callback_followup_keeps_existing_thread_behavior(monkeypatch):\n+    adapter = _make_adapter()\n+    adapter._slash_confirm_state = {\"confirm-1\": \"session-1\"}\n+    adapter._is_callback_user_authorized = lambda *args, **kwargs: True\n+    call_log = []\n+\n+    async def mock_send_message(**kwargs):\n+        call_log.append(dict(kwargs))\n+        return SimpleNamespace(message_id=9001)\n+\n+    async def resolve(_session_key, _confirm_id, _choice):\n+        return \"done\"\n+\n+    from tools import slash_confirm\n+\n+    monkeypatch.setattr(slash_confirm, \"resolve\", resolve)\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    class Query:\n+        data = \"sc:once:confirm-1\"\n+        from_user = SimpleNamespace(id=42, first_name=\"Alice\")\n+        message = SimpleNamespace(\n+            chat_id=-100123,\n+            chat=SimpleNamespace(type=_fake_telegram_constants.ChatType.SUPERGROUP),\n+            message_thread_id=20197,\n+            message_id=462,\n+        )\n+\n+        async def answer(self, **kwargs):\n+            return None\n+\n+        async def edit_message_text(self, **kwargs):\n+            return None\n+\n+    await adapter._handle_callback_query(SimpleNamespace(callback_query=Query()), SimpleNamespace())\n+\n+    assert call_log\n+    assert call_log[0][\"message_thread_id\"] == 20197\n+    assert \"reply_to_message_id\" not in call_log[0]\n+    assert \"direct_messages_topic_id\" not in call_log[0]\n+\n+\n[redacted:email]\n+async def test_base_send_image_fallback_preserves_metadata():\n+    \"\"\"Base image fallback should pass metadata through instead of referencing kwargs.\"\"\"\n+    from gateway.platforms.base import BasePlatformAdapter\n+\n+    class _ConcreteBaseAdapter(BasePlatformAdapter):\n+        async def connect(self):\n+            return True\n+\n+        async def disconnect(self):\n+            return None\n+\n+        async def send(self, **kwargs):\n+            call_log.append(kwargs)\n+            return SendResult(success=True, message_id=\"781\")\n+\n+        async def get_chat_info(self, chat_id):\n+            return None\n+\n+    call_log = []\n+    adapter = _ConcreteBaseAdapter(Platform.TELEGRAM, None)\n+    metadata = {\"thread_id\": \"20197\"}\n+\n+    result = await adapter.send_image(\n+        chat_id=\"123\",\n+        image_url=\"https://example.invalid/image.png\",\n+        metadata=metadata,\n+    )\n+\n+    assert result.success is True\n+    assert call_log[0][\"metadata\"] is metadata\n+\n+\n @pytest.mark.asyncio\n async def test_send_raises_on_other_bad_request():\n     \"\"\"Non-thread BadRequest errors should NOT be retried — they fail immediately.\"\"\""]},{"file":"tests/gateway/test_voice_command.py","hunks":["@@ -433,6 +433,37 @@ async def test_calls_tts_and_send_voice(self, runner):\n         call_args = mock_adapter.send_voice.call_args\n         assert call_args.kwargs.get(\"chat_id\") == \"123\"\n \n+    @pytest.mark.asyncio\n+    async def test_auto_voice_reply_uses_thread_metadata_helper(self, runner):\n+        from gateway.config import Platform\n+\n+        mock_adapter = AsyncMock()\n+        mock_adapter.send_voice = AsyncMock()\n+        event = _make_event()\n+        event.source.platform = Platform.TELEGRAM\n+        event.source.chat_type = \"dm\"\n+        event.source.thread_id = \"20197\"\n+        event.message_id = \"462\"\n+        runner.adapters[event.source.platform] = mock_adapter\n+\n+        tts_result = json.dumps({\"success\": True, \"file_path\": \"/tmp/test.ogg\"})\n+\n+        with patch(\"tools.tts_tool.text_to_speech_tool\", return_value=tts_result), \\\n+             patch(\"tools.tts_tool._strip_markdown_for_tts\", side_effect=lambda t: t), \\\n+             patch(\"os.path.isfile\", return_value=True), \\\n+             patch(\"os.unlink\"), \\\n+             patch(\"os.makedirs\"):\n+            await runner._send_voice_reply(event, \"Hello world\")\n+\n+        mock_adapter.send_voice.assert_called_once()\n+        call_kwargs = mock_adapter.send_voice.call_args.kwargs\n+        assert call_kwargs[\"reply_to\"] == \"462\"\n+        assert call_kwargs[\"metadata\"] == {\n+            \"thread_id\": \"20197\",\n+            \"telegram_dm_topic_reply_fallback\": True,\n+            \"telegram_reply_to_message_id\": \"462\",\n+        }\n+\n     @pytest.mark.asyncio\n     async def test_empty_text_after_strip_skips(self, runner):\n         event = _make_event()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}