{"repo":"NousResearch/hermes-agent","public_id":"7519","display_label":"Telegram forum topic thread_id routing fixes","summary":"These issues and pull requests address bugs where messages, cron deliveries, typing indicators, and model picker interactions fail to route correctly to Telegram forum topics and DM topics, often falling back to the General topic. The work involves fixing thread_id resolution, propagation, and fallback logic across multiple send paths.","subsystem":"cron","counts":{"members":33,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":13,"merged_prs":6,"attempt_prs":14},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":10775},"resolution":{"state":"partially_resolved","by":"pr-10775,pr-22410,pr-22423,pr-50023,pr-58165"},"freshness":{"state":"verified","last_verified_at":"2026-07-04T09:08:52+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.049848484848484836},"fix_edges":[{"issue_number":7519,"pr_number":10775,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":7877,"pr_number":10775,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"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},{"issue_number":22267,"pr_number":22423,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":22773,"pr_number":50023,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":27012,"pr_number":28514,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":52060,"pr_number":58165,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":7519,"title":"fix(telegram): restore typing indicator in forum General topic","url":null,"state":"closed"},{"kind":"issue","number":7877,"title":"[Bug]: Typing indicator missing in Telegram General topic for forum-enabled chats","url":null,"state":"closed"},{"kind":"issue","number":20104,"title":"[Bug]:  send_message text/media/files are delivered to general topic instead of current thread; voice routing works correctly","url":null,"state":"closed"},{"kind":"issue","number":21981,"title":"[Bug]: Telegram topic-enabled DM shows typing but never delivers response","url":null,"state":"closed"},{"kind":"issue","number":22022,"title":"Telegram Bot API 10 breaks private chat topic replies; Hermes falls back to All Messages","url":null,"state":"closed"},{"kind":"issue","number":22267,"title":"[Bug]: Telegram Send Failed: \"Message thread not found\" when sending to Groups with Topics enabled","url":null,"state":"closed"},{"kind":"issue","number":22773,"title":"Cron delivery to private DM topics broken after PR #22410 — send_message_tool.py missing three-mode routing","url":null,"state":"closed"},{"kind":"issue","number":27012,"title":"send_message fails to deliver to Telegram forum topics (telegram:chat_id:thread_id)","url":null,"state":"closed"},{"kind":"issue","number":48056,"title":"Telegram DM topic cron delivery falls back out of topic","url":null,"state":"closed"},{"kind":"issue","number":52060,"title":"Cron Telegram nudges land in General topic — #22773 DM-topic heuristic mis-classifies threaded-DM forum topics","url":null,"state":"closed"},{"kind":"issue","number":55265,"title":"[Bug] Cron delivery to private chat forum-topics lands in General — direct_messages_topic_id silently ignored (regression from #50023)","url":null,"state":"closed"},{"kind":"issue","number":57891,"title":"DM topic delivery fails after gateway restart: reply anchor dropped but direct_messages_topic_id fallback blocked by early return","url":null,"state":"closed"},{"kind":"issue","number":65065,"title":"Cron delivery to Telegram topic misroutes to General topic despite correct message_thread_id","url":null,"state":"closed"},{"kind":"pr","number":7892,"title":"fix telegram general topic typing","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10775","number":10775}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":10775,"title":"fix(telegram): restore typing indicator and thread routing for forum General topic (#7892)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/telegram.py","tests/gateway/test_telegram_thread_fallback.py"],"fix_hunks":[{"file":"gateway/platforms/telegram.py","hunks":["@@ -134,6 +134,7 @@ class TelegramAdapter(BasePlatformAdapter):\n     # When a chunk is near this limit, a continuation is almost certain.\n     _SPLIT_THRESHOLD = 4000\n     MEDIA_GROUP_WAIT_SECONDS = 0.8\n+    _GENERAL_TOPIC_THREAD_ID = \"1\"\n     \n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.TELEGRAM)","@@ -178,6 +179,29 @@ def _is_callback_user_authorized(user_id: str) -> bool:\n         allowed_ids = {uid.strip() for uid in allowed_csv.split(\",\") if uid.strip()}\n         return \"*\" in allowed_ids or user_id in allowed_ids\n \n+    @classmethod\n+    def _metadata_thread_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]:\n+        if not metadata:\n+            return None\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 _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:\n+            return None\n+        return int(thread_id)\n+\n+    @classmethod\n+    def _message_thread_id_for_typing(cls, thread_id: Optional[str]) -> Optional[int]:\n+        if not thread_id:\n+            return None\n+        return int(thread_id)\n+\n+    @staticmethod\n+    def _is_thread_not_found_error(error: Exception) -> bool:\n+        return \"thread not found\" in str(error).lower()\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 []","@@ -849,7 +873,7 @@ async def send(\n                 ]\n             \n             message_ids = []\n-            thread_id = metadata.get(\"thread_id\") if metadata else None\n+            thread_id = self._metadata_thread_id(metadata)\n             \n             try:\n                 from telegram.error import NetworkError as _NetErr","@@ -869,7 +893,7 @@ async def send(\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 = int(thread_id) if thread_id else None\n+                effective_thread_id = self._message_thread_id_for_send(thread_id)\n \n                 msg = None\n                 for _send_attempt in range(3):","@@ -906,8 +930,7 @@ async def send(\n                         # (not transient network issues). Detect and handle\n                         # specific cases instead of blindly retrying.\n                         if _BadReq and isinstance(send_err, _BadReq):\n-                            err_lower = str(send_err).lower()\n-                            if \"thread not found\" in err_lower and effective_thread_id is not None:\n+                            if self._is_thread_not_found_error(send_err) and effective_thread_id is not None:\n                                 # Thread doesn't exist — retry without\n                                 # message_thread_id so the message still\n                                 # reaches the chat.","@@ -917,6 +940,7 @@ async def send(\n                                 )\n                                 effective_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","@@ -1115,9 +1139,7 @@ async def send_exec_approval(\n             )\n \n             # Resolve thread context for thread replies\n-            thread_id = None\n-            if metadata:\n-                thread_id = metadata.get(\"thread_id\") or metadata.get(\"message_thread_id\")\n+            thread_id = self._metadata_thread_id(metadata)\n \n             # We'll use the message_id as part of callback_data to look up session_key\n             # Send a placeholder first, then update — or use a counter.","@@ -1145,8 +1167,9 @@ async def send_exec_approval(\n                 \"reply_markup\": keyboard,\n                 **self._link_preview_kwargs(),\n             }\n-            if thread_id:\n-                kwargs[\"message_thread_id\"] = int(thread_id)\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 \n             msg = await self._bot.send_message(**kwargs)\n ","@@ -1579,23 +1602,23 @@ async def send_voice(\n             with open(audio_path, \"rb\") as audio_file:\n                 # .ogg files -> send as voice (round playable bubble)\n                 if audio_path.endswith((\".ogg\", \".opus\")):\n-                    _voice_thread = metadata.get(\"thread_id\") if metadata else None\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=int(_voice_thread) if _voice_thread else None,\n+                        message_thread_id=self._message_thread_id_for_send(_voice_thread),\n                     )\n                 else:\n                     # .mp3 and others -> send as audio file\n-                    _audio_thread = metadata.get(\"thread_id\") if metadata else None\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=int(_audio_thread) if _audio_thread else None,\n+                        message_thread_id=self._message_thread_id_for_send(_audio_thread),\n                     )\n             return SendResult(success=True, message_id=str(msg.message_id))\n         except Exception as e:","@@ -1625,14 +1648,14 @@ async def send_image_file(\n             if not os.path.exists(image_path):\n                 return SendResult(success=False, error=f\"Image file not found: {image_path}\")\n \n-            _thread = metadata.get(\"thread_id\") if metadata else None\n+            _thread = self._metadata_thread_id(metadata)\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=int(_thread) if _thread else None,\n+                    message_thread_id=self._message_thread_id_for_send(_thread),\n                 )\n             return SendResult(success=True, message_id=str(msg.message_id))\n         except Exception as e:","@@ -1663,7 +1686,7 @@ async def send_document(\n                 return SendResult(success=False, error=f\"File not found: {file_path}\")\n \n             display_name = file_name or os.path.basename(file_path)\n-            _thread = metadata.get(\"thread_id\") if metadata else None\n+            _thread = self._metadata_thread_id(metadata)\n \n             with open(file_path, \"rb\") as f:\n                 msg = await self._bot.send_document(","@@ -1672,7 +1695,7 @@ async def send_document(\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=int(_thread) if _thread else None,\n+                    message_thread_id=self._message_thread_id_for_send(_thread),\n                 )\n             return SendResult(success=True, message_id=str(msg.message_id))\n         except Exception as e:","@@ -1696,14 +1719,14 @@ async def send_video(\n             if not os.path.exists(video_path):\n                 return SendResult(success=False, error=f\"Video file not found: {video_path}\")\n \n-            _thread = metadata.get(\"thread_id\") if metadata else None\n+            _thread = self._metadata_thread_id(metadata)\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=int(_thread) if _thread else None,\n+                    message_thread_id=self._message_thread_id_for_send(_thread),\n                 )\n             return SendResult(success=True, message_id=str(msg.message_id))\n         except Exception as e:","@@ -1733,13 +1756,13 @@ async def send_image(\n \n         try:\n             # Telegram can send photos directly from URLs (up to ~5MB)\n-            _photo_thread = metadata.get(\"thread_id\") if metadata else None\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=int(_photo_thread) if _photo_thread else None,\n+                message_thread_id=self._message_thread_id_for_send(_photo_thread),\n             )\n             return SendResult(success=True, message_id=str(msg.message_id))\n         except Exception as e:","@@ -1762,6 +1785,7 @@ async def send_image(\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                 return SendResult(success=True, message_id=str(msg.message_id))\n             except Exception as e2:","@@ -1787,13 +1811,13 @@ async def send_animation(\n             return SendResult(success=False, error=\"Not connected\")\n         \n         try:\n-            _anim_thread = metadata.get(\"thread_id\") if metadata else None\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=int(_anim_thread) if _anim_thread else None,\n+                message_thread_id=self._message_thread_id_for_send(_anim_thread),\n             )\n             return SendResult(success=True, message_id=str(msg.message_id))\n         except Exception as e:","@@ -1810,12 +1834,23 @@ async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = N\n         \"\"\"Send typing indicator.\"\"\"\n         if self._bot:\n             try:\n-                _typing_thread = metadata.get(\"thread_id\") if metadata else None\n-                await self._bot.send_chat_action(\n-                    chat_id=int(chat_id),\n-                    action=\"typing\",\n-                    message_thread_id=int(_typing_thread) if _typing_thread else None,\n-                )\n+                _typing_thread = self._metadata_thread_id(metadata)\n+                message_thread_id = self._message_thread_id_for_typing(_typing_thread)\n+                try:\n+                    await self._bot.send_chat_action(\n+                        chat_id=int(chat_id),\n+                        action=\"typing\",\n+                        message_thread_id=message_thread_id,\n+                    )\n+                except Exception as e:\n+                    if message_thread_id is not None and self._is_thread_not_found_error(e):\n+                        await self._bot.send_chat_action(\n+                            chat_id=int(chat_id),\n+                            action=\"typing\",\n+                            message_thread_id=None,\n+                        )\n+                    else:\n+                        raise\n             except Exception as e:\n                 # Typing failures are non-fatal; log at debug level only.\n                 logger.debug(","@@ -2760,7 +2795,9 @@ def _build_message_event(self, message: Message, msg_type: MessageType) -> Messa\n \n         # Resolve DM topic name and skill binding\n         thread_id_raw = message.message_thread_id\n-        thread_id_str = str(thread_id_raw) if thread_id_raw else None\n+        thread_id_str = str(thread_id_raw) if thread_id_raw is not None else None\n+        if chat_type == \"group\" and thread_id_str is None and getattr(chat, \"is_forum\", False):\n+            thread_id_str = self._GENERAL_TOPIC_THREAD_ID\n         chat_topic = None\n         topic_skill = None\n "]},{"file":"tests/gateway/test_telegram_thread_fallback.py","hunks":["@@ -45,6 +45,11 @@ def __init__(self, seconds):\n \n # Build a fake telegram module tree so the adapter's internal imports work\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_error = types.ModuleType(\"telegram.error\")\n _fake_telegram_error.NetworkError = FakeNetworkError\n _fake_telegram_error.BadRequest = FakeBadRequest","@@ -52,7 +57,21 @@ def __init__(self, seconds):\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.ChatType = SimpleNamespace(\n+    GROUP=\"group\",\n+    SUPERGROUP=\"supergroup\",\n+    CHANNEL=\"channel\",\n+)\n _fake_telegram.constants = _fake_telegram_constants\n+_fake_telegram_ext = types.ModuleType(\"telegram.ext\")\n+_fake_telegram_ext.Application = object\n+_fake_telegram_ext.CommandHandler = object\n+_fake_telegram_ext.CallbackQueryHandler = object\n+_fake_telegram_ext.MessageHandler = object\n+_fake_telegram_ext.ContextTypes = SimpleNamespace(DEFAULT_TYPE=object)\n+_fake_telegram_ext.filters = object\n+_fake_telegram_request = types.ModuleType(\"telegram.request\")\n+_fake_telegram_request.HTTPXRequest = object\n \n \n @pytest.fixture(autouse=True)","@@ -61,6 +80,8 @@ def _inject_fake_telegram(monkeypatch):\n     monkeypatch.setitem(sys.modules, \"telegram\", _fake_telegram)\n     monkeypatch.setitem(sys.modules, \"telegram.error\", _fake_telegram_error)\n     monkeypatch.setitem(sys.modules, \"telegram.constants\", _fake_telegram_constants)\n+    monkeypatch.setitem(sys.modules, \"telegram.ext\", _fake_telegram_ext)\n+    monkeypatch.setitem(sys.modules, \"telegram.request\", _fake_telegram_request)\n \n \n def _make_adapter():","@@ -68,6 +89,7 @@ def _make_adapter():\n \n     config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n     adapter = object.__new__(TelegramAdapter)\n+    adapter.config = config\n     adapter._config = config\n     adapter._platform = Platform.TELEGRAM\n     adapter._connected = True","@@ -82,6 +104,81 @@ def _make_adapter():\n     return adapter\n \n \n+def test_forum_general_topic_without_message_thread_id_keeps_thread_context():\n+    \"\"\"Forum General-topic messages should keep synthetic thread context.\"\"\"\n+    from gateway.platforms import telegram as telegram_mod\n+\n+    adapter = _make_adapter()\n+    message = SimpleNamespace(\n+        text=\"hello from General\",\n+        caption=None,\n+        chat=SimpleNamespace(\n+            id=-100123,\n+            type=telegram_mod.ChatType.SUPERGROUP,\n+            is_forum=True,\n+            title=\"Forum group\",\n+        ),\n+        from_user=SimpleNamespace(id=456, full_name=\"Alice\"),\n+        message_thread_id=None,\n+        reply_to_message=None,\n+        message_id=10,\n+        date=None,\n+    )\n+\n+    event = adapter._build_message_event(message, msg_type=SimpleNamespace(value=\"text\"))\n+\n+    assert event.source.chat_id == \"-100123\"\n+    assert event.source.chat_type == \"group\"\n+    assert event.source.thread_id == \"1\"\n+\n+\n[redacted:email]\n+async def test_send_omits_general_topic_thread_id():\n+    \"\"\"Telegram sends to forum General should omit message_thread_id=1.\"\"\"\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=\"-100123\",\n+        content=\"test message\",\n+        metadata={\"thread_id\": \"1\"},\n+    )\n+\n+    assert result.success is True\n+    assert len(call_log) == 1\n+    assert call_log[0][\"chat_id\"] == -100123\n+    assert call_log[0][\"text\"] == \"test message\"\n+    assert call_log[0][\"reply_to_message_id\"] is None\n+    assert call_log[0][\"message_thread_id\"] is None\n+\n+\n[redacted:email]\n+async def test_send_typing_retries_without_general_thread_when_not_found():\n+    \"\"\"Typing for forum General should fall back if Telegram rejects thread 1.\"\"\"\n+    adapter = _make_adapter()\n+    call_log = []\n+\n+    async def mock_send_chat_action(**kwargs):\n+        call_log.append(dict(kwargs))\n+        if kwargs.get(\"message_thread_id\") == 1:\n+            raise FakeBadRequest(\"Message thread not found\")\n+\n+    adapter._bot = SimpleNamespace(send_chat_action=mock_send_chat_action)\n+\n+    await adapter.send_typing(\"-100123\", metadata={\"thread_id\": \"1\"})\n+\n+    assert call_log == [\n+        {\"chat_id\": -100123, \"action\": \"typing\", \"message_thread_id\": 1},\n+        {\"chat_id\": -100123, \"action\": \"typing\", \"message_thread_id\": None},\n+    ]\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.\"\"\"\n"]}]},{"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":19961,"title":"fix(telegram): omit General topic id for 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":20110,"title":"fix(send_message): resolve thread_id from session context for text/media/file sends","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c6c8abbadb802dc389d8488a8847f9aa93bf9350","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":21542,"title":"fix: send_model_picker fails in Telegram General topic (Message thread not found)","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":22053,"title":"fix(telegram): preserve DM topic routing via reply fallback","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b3239572f","number":0},{"kind":"pr","value":"#22410","number":22410},{"kind":"pr","value":"#22410","number":22410}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":22383,"title":"fix(gateway/telegram): route /model picker thread_id through helper","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"]}]},{"kind":"pr","number":22423,"title":"fix(send_message): map Telegram General topic id to None for forum groups","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_send_message_tool.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -742,6 +742,64 @@ def test_transient_bad_gateway_retries_text_send(self, monkeypatch):\n         sleep_mock.assert_awaited_once()\n \n \n+class TestSendTelegramThreadIdMapping:\n+    \"\"\"General-topic mapping in _send_telegram (issue #22267).\n+\n+    Telegram forum supergroups address the General topic as\n+    ``message_thread_id=\"1\"`` on incoming updates, but the Bot API rejects\n+    sends with ``message_thread_id=1`` (\"Message thread not found\"). The\n+    gateway adapter's ``_message_thread_id_for_send`` helper maps \"1\" to\n+    ``None`` for that reason; the standalone ``_send_telegram`` helper used\n+    by the ``send_message`` tool needs the same mapping.\n+    \"\"\"\n+\n+    def _make_bot(self):\n+        bot = MagicMock()\n+        bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1))\n+        return bot\n+\n+    def test_general_topic_thread_id_omitted(self, monkeypatch):\n+        \"\"\"thread_id=\"1\" must be dropped before calling the Bot API.\"\"\"\n+        bot = self._make_bot()\n+        _install_telegram_mock(monkeypatch, bot)\n+\n+        asyncio.run(_send_telegram(\"tok\", \"-1001234567890\", \"hello\", thread_id=\"1\"))\n+\n+        bot.send_message.assert_awaited_once()\n+        kwargs = bot.send_message.await_args.kwargs\n+        assert \"message_thread_id\" not in kwargs\n+\n+    def test_non_general_topic_thread_id_preserved(self, monkeypatch):\n+        \"\"\"Real forum-topic thread ids (>1) still pass through as ints.\"\"\"\n+        bot = self._make_bot()\n+        _install_telegram_mock(monkeypatch, bot)\n+\n+        asyncio.run(_send_telegram(\"tok\", \"-1001234567890\", \"hello\", thread_id=\"17585\"))\n+\n+        kwargs = bot.send_message.await_args.kwargs\n+        assert kwargs[\"message_thread_id\"] == 17585\n+\n+    def test_no_thread_id_no_kwarg(self, monkeypatch):\n+        \"\"\"With no thread_id, message_thread_id must not appear in kwargs.\"\"\"\n+        bot = self._make_bot()\n+        _install_telegram_mock(monkeypatch, bot)\n+\n+        asyncio.run(_send_telegram(\"tok\", \"-1001234567890\", \"hello\"))\n+\n+        kwargs = bot.send_message.await_args.kwargs\n+        assert \"message_thread_id\" not in kwargs\n+\n+    def test_general_topic_thread_id_int_input_also_dropped(self, monkeypatch):\n+        \"\"\"thread_id passed as the int 1 (not str) must still be dropped.\"\"\"\n+        bot = self._make_bot()\n+        _install_telegram_mock(monkeypatch, bot)\n+\n+        asyncio.run(_send_telegram(\"tok\", \"-1001234567890\", \"hello\", thread_id=1))\n+\n+        kwargs = bot.send_message.await_args.kwargs\n+        assert \"message_thread_id\" not in kwargs\n+\n+\n # ---------------------------------------------------------------------------\n # Tests for Discord thread_id support\n # ---------------------------------------------------------------------------"]},{"file":"tools/send_message_tool.py","hunks":["@@ -710,7 +710,27 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n         media_files = media_files or []\n         thread_kwargs = {}\n         if thread_id is not None:\n-            thread_kwargs[\"message_thread_id\"] = int(thread_id)\n+            # Reuse the gateway adapter's General-topic mapping: in Telegram\n+            # forum supergroups, the General topic is addressed as\n+            # message_thread_id=\"1\" on incoming updates, but Bot API\n+            # sendMessage rejects message_thread_id=1 with \"Message thread\n+            # not found\". The adapter's helper maps \"1\" to None for that\n+            # reason; the send_message tool needs the same mapping or a\n+            # send to a forum group's General topic always errors out\n+            # (see issue #22267).\n+            try:\n+                from gateway.platforms.telegram import TelegramAdapter\n+                effective_thread_id = TelegramAdapter._message_thread_id_for_send(\n+                    str(thread_id)\n+                )\n+            except Exception:\n+                # Fallback: explicit mapping in case the adapter import\n+                # fails (e.g. python-telegram-bot missing in this venv).\n+                effective_thread_id = (\n+                    None if str(thread_id) == \"1\" else int(thread_id)\n+                )\n+            if effective_thread_id is not None:\n+                thread_kwargs[\"message_thread_id\"] = effective_thread_id\n         if disable_link_previews:\n             thread_kwargs[\"disable_web_page_preview\"] = True\n \n"]}]},{"kind":"pr","number":23249,"title":"fix(cron): retry Telegram DM topic delivery with reply anchor on thread-not-found","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3051a1634","number":0},{"kind":"pr","value":"#22410","number":22410},{"kind":"pr","value":"#22773","number":22773},{"kind":"pr","value":"#50023","number":50023}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":27098,"title":"fix(send_message): add thread-not-found retry for Telegram forum topic sends (#27012)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28514","number":28514}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28514,"title":"fix(send_message): add thread-not-found retry for Telegram forum topic sends (#27012)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_send_message_tool.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -176,6 +176,7 @@\n     \"[redacted:email]\": \"samahn0601\",  # PR #27887 salvage (retry wrapped connect timeouts)\n     \"[redacted:email]\": \"aqilaziz\",  # PR #26406 salvage (preserve native audio outside Telegram)\n     \"[redacted:email]\": \"karthikeyann\",  # PR #26609 salvage (DM-topic routing pin)\n+    \"[redacted:email]\": \"kunci115\",  # PR #27098 salvage (thread-not-found retry)\n     \"[redacted:email]\": \"eliteworkstation94-ai\",  # PR #28157 salvage (group reply session splits)\n     \"[redacted:email]\": \"hllqkb\",\n     \"[redacted:email]\": \"Bongulielmi\","]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -23,6 +23,7 @@ def _reset_signal_scheduler():\n from gateway.config import Platform\n from tools.send_message_tool import (\n     _derive_forum_thread_name,\n+    _is_telegram_thread_not_found,\n     _parse_target_ref,\n     _send_discord,\n     _send_matrix_via_adapter,","@@ -876,6 +877,59 @@ def test_general_topic_thread_id_int_input_also_dropped(self, monkeypatch):\n         kwargs = bot.send_message.await_args.kwargs\n         assert \"message_thread_id\" not in kwargs\n \n+    def test_thread_not_found_retries_without_message_thread_id(self, monkeypatch):\n+        \"\"\"When send_message raises \"thread not found\", retry without thread_id (#27012).\"\"\"\n+        bot = self._make_bot()\n+        _install_telegram_mock(monkeypatch, bot)\n+\n+        # First call raises thread-not-found, second succeeds\n+        bot.send_message = AsyncMock(side_effect=[\n+            Exception(\"Bad Request: message thread not found\"),\n+            SimpleNamespace(message_id=2),\n+        ])\n+\n+        asyncio.run(\n+            _send_telegram(\"tok\", \"-1001234567890\", \"hello\", thread_id=\"17585\")\n+        )\n+\n+        assert bot.send_message.await_count == 2\n+        # First call: should include message_thread_id=17585\n+        call1_kwargs = bot.send_message.await_args_list[0].kwargs\n+        assert call1_kwargs[\"message_thread_id\"] == 17585\n+        # Second call (retry): should NOT include message_thread_id\n+        call2_kwargs = bot.send_message.await_args_list[1].kwargs\n+        assert \"message_thread_id\" not in call2_kwargs\n+\n+    def test_thread_not_found_for_media_retries_without_message_thread_id(self, monkeypatch, tmp_path):\n+        \"\"\"Media send with stale thread_id retries without it (#27012).\"\"\"\n+        bot = self._make_bot()\n+        # Mock send_document to fail with thread-not-found, then succeed\n+        bot.send_document = AsyncMock(side_effect=[\n+            Exception(\"Bad Request: message thread not found\"),\n+            SimpleNamespace(message_id=3),\n+        ])\n+        _install_telegram_mock(monkeypatch, bot)\n+\n+        # Create a test file\n+        test_file = tmp_path / \"doc.txt\"\n+        test_file.write_text(\"test content\")\n+\n+        asyncio.run(\n+            _send_telegram(\n+                \"tok\", \"-1001234567890\", \"\",\n+                media_files=[(str(test_file), False)],\n+                thread_id=\"17585\",\n+            )\n+        )\n+\n+        assert bot.send_document.await_count == 2\n+        # First call: should include message_thread_id=17585\n+        call1_kwargs = bot.send_document.await_args_list[0].kwargs\n+        assert call1_kwargs[\"message_thread_id\"] == 17585\n+        # Second call (retry): should NOT include message_thread_id\n+        call2_kwargs = bot.send_document.await_args_list[1].kwargs\n+        assert \"message_thread_id\" not in call2_kwargs\n+\n \n # ---------------------------------------------------------------------------\n # Tests for Discord thread_id support","@@ -2425,3 +2479,94 @@ def test_gateway_status_import_error_is_swallowed(self, monkeypatch):\n              patch(\"gateway.status.is_gateway_running\",\n                    side_effect=ImportError(\"simulated\")):\n             assert _check_send_message() is False\n+\n+\n+class TestSendTelegramThreadNotFoundRetry:\n+    \"\"\"Tests for thread-not-found retry behaviour in _send_telegram (#27012).\"\"\"\n+\n+    def test_is_thread_not_found_matches_expected_errors(self):\n+        \"\"\"_is_telegram_thread_not_found should detect thread-not-found errors.\"\"\"\n+        class FakeError(Exception):\n+            pass\n+\n+        assert _is_telegram_thread_not_found(FakeError(\"message thread not found\")) is True\n+        assert _is_telegram_thread_not_found(FakeError(\"THREAD NOT FOUND\")) is True\n+        assert _is_telegram_thread_not_found(FakeError(\"Bad Request: thread not found\")) is True\n+        assert _is_telegram_thread_not_found(FakeError(\"chat not found\")) is False\n+        assert _is_telegram_thread_not_found(FakeError(\"parse error\")) is False\n+        assert _is_telegram_thread_not_found(FakeError(\"\")) is False\n+\n+    def test_text_send_retries_without_thread_id_on_thread_not_found(self):\n+        \"\"\"When thread is not found, the text send should retry without\n+        message_thread_id.\"\"\"\n+        call_args = []\n+\n+        async def fake_retry(bot, *, chat_id, text, parse_mode, **kwargs):\n+            call_args.append(dict(kwargs, chat_id=chat_id, text=text))\n+            if len(call_args) == 1:\n+                raise Exception(\"Bad Request: message thread not found\")\n+            return SimpleNamespace(message_id=42)\n+\n+        async def run_test():\n+            with patch(\n+                \"tools.send_message_tool._send_telegram_message_with_retry\",\n+                fake_retry,\n+            ):\n+                # _send_telegram imports Bot locally; we only need to mock\n+                # the send path, not Bot itself (Bot import falls through\n+                # normally since python-telegram-bot is installed).\n+                return await _send_telegram(\n+                    \"fake-token\", \"-100123\", \"hello from topic 17585\",\n+                    thread_id=\"17585\",\n+                )\n+\n+        result = asyncio.run(run_test())\n+        assert result[\"success\"] is True\n+        assert result[\"message_id\"] == \"42\"\n+        assert len(call_args) == 2, f\"expected 2 calls, got {len(call_args)}\"\n+        # First call should have message_thread_id\n+        assert call_args[0].get(\"message_thread_id\") is not None\n+        # Second call (retry) should NOT have message_thread_id\n+        assert \"message_thread_id\" not in call_args[1], \\\n+            \"retry should drop message_thread_id after thread-not-found\"\n+\n+    def test_disable_web_page_preview_not_leaked_to_media_sends(self):\n+        \"\"\"disable_web_page_preview should only appear in text send, not media sends.\"\"\"\n+        text_kwargs_seen = []\n+        media_kwargs_seen = []\n+\n+        class FakeBot:\n+            async def send_message(self, **kwargs):\n+                text_kwargs_seen.append(kwargs)\n+                return SimpleNamespace(message_id=1)\n+\n+            async def send_document(self, **kwargs):\n+                media_kwargs_seen.append(kwargs)\n+                return SimpleNamespace(message_id=2)\n+\n+        import tempfile\n+        media_path = None\n+        try:\n+            with tempfile.NamedTemporaryFile(suffix=\".pdf\", delete=False) as tf:\n+                tf.write(b\"%PDF-1.4 test content\")\n+                media_path = tf.name\n+\n+            async def run_test():\n+                with patch(\"telegram.Bot\", return_value=FakeBot()):\n+                    return await _send_telegram(\n+                        \"fake-token\", \"-100123\", \"check preview\",\n+                        media_files=[(media_path, False)],\n+                        disable_link_previews=True,\n+                    )\n+\n+            result = asyncio.run(run_test())\n+            assert result[\"success\"] is True\n+            # Text send should have disable_web_page_preview\n+            assert text_kwargs_seen[0].get(\"disable_web_page_preview\") is True\n+            # Media send should NOT have disable_web_page_preview\n+            assert \"disable_web_page_preview\" not in media_kwargs_seen[0], \\\n+                \"disable_web_page_preview leaked into send_document kwargs\"\n+        finally:\n+            if media_path and os.path.exists(media_path):\n+                os.unlink(media_path)\n+"]},{"file":"tools/send_message_tool.py","hunks":["@@ -786,6 +786,15 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n     return last_result\n \n \n+def _is_telegram_thread_not_found(error: Exception) -> bool:\n+    \"\"\"Check if a Telegram error is a thread-not-found failure.\n+\n+    Matches the gateway adapter's ``_is_thread_not_found_error`` for\n+    the standalone ``_send_telegram`` path (issue #27012).\n+    \"\"\"\n+    return \"thread not found\" in str(error).lower()\n+\n+\n async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False, force_document=False):\n     \"\"\"Send via Telegram Bot API (one-shot, no polling needed).\n ","@@ -873,8 +882,12 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n                 )\n             if effective_thread_id is not None:\n                 thread_kwargs[\"message_thread_id\"] = effective_thread_id\n+        # disable_web_page_preview is only valid for send_message, not\n+        # send_photo/send_video/etc.  Keep it separate so media sends\n+        # don't inherit an invalid parameter (issue #27012).\n+        text_kwargs = dict(thread_kwargs)\n         if disable_link_previews:\n-            thread_kwargs[\"disable_web_page_preview\"] = True\n+            text_kwargs[\"disable_web_page_preview\"] = True\n \n         last_msg = None\n         warnings = []","@@ -884,11 +897,24 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n                 last_msg = await _send_telegram_message_with_retry(\n                     bot,\n                     chat_id=int_chat_id, text=formatted,\n-                    parse_mode=send_parse_mode, entities=_entities, **thread_kwargs\n+                    parse_mode=send_parse_mode, entities=_entities, **text_kwargs\n                 )\n             except Exception as md_error:\n-                # Parse failed, fall back to plain text\n-                if \"parse\" in str(md_error).lower() or \"markdown\" in str(md_error).lower() or \"html\" in str(md_error).lower():\n+                # Thread not found — retry without message_thread_id so the\n+                # message still delivers (matching the gateway adapter's\n+                # fallback behaviour, issue #27012).\n+                if _is_telegram_thread_not_found(md_error) and thread_kwargs:\n+                    logger.warning(\n+                        \"Thread %s not found in _send_telegram, retrying without message_thread_id\",\n+                        thread_kwargs.get(\"message_thread_id\"),\n+                    )\n+                    text_kwargs.pop(\"message_thread_id\", None)\n+                    last_msg = await _send_telegram_message_with_retry(\n+                        bot,\n+                        chat_id=int_chat_id, text=formatted,\n+                        parse_mode=send_parse_mode, **text_kwargs\n+                    )\n+                elif \"parse\" in str(md_error).lower() or \"markdown\" in str(md_error).lower() or \"html\" in str(md_error).lower():\n                     logger.warning(\n                         \"Parse mode %s failed in _send_telegram, falling back to plain text: %s\",\n                         send_parse_mode,","@@ -905,7 +931,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n                     last_msg = await _send_telegram_message_with_retry(\n                         bot,\n                         chat_id=int_chat_id, text=plain,\n-                        parse_mode=None, entities=_entities, **thread_kwargs\n+                        parse_mode=None, entities=_entities, **text_kwargs\n                     )\n                 else:\n                     raise","@@ -920,26 +946,61 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n             ext = os.path.splitext(media_path)[1].lower()\n             try:\n                 with open(media_path, \"rb\") as f:\n-                    if ext in _IMAGE_EXTS and not force_document:\n-                        last_msg = await bot.send_photo(\n-                            chat_id=int_chat_id, photo=f, **thread_kwargs\n-                        )\n-                    elif ext in _VIDEO_EXTS:\n-                        last_msg = await bot.send_video(\n-                            chat_id=int_chat_id, video=f, **thread_kwargs\n-                        )\n-                    elif ext in _VOICE_EXTS and is_voice:\n-                        last_msg = await bot.send_voice(\n-                            chat_id=int_chat_id, voice=f, **thread_kwargs\n-                        )\n-                    elif ext in _TELEGRAM_SEND_AUDIO_EXTS:\n-                        last_msg = await bot.send_audio(\n-                            chat_id=int_chat_id, audio=f, **thread_kwargs\n-                        )\n-                    else:\n-                        last_msg = await bot.send_document(\n-                            chat_id=int_chat_id, document=f, **thread_kwargs\n-                        )\n+                    media_kwargs = dict(thread_kwargs)\n+                    try:\n+                        if ext in _IMAGE_EXTS and not force_document:\n+                            last_msg = await bot.send_photo(\n+                                chat_id=int_chat_id, photo=f, **media_kwargs\n+                            )\n+                        elif ext in _VIDEO_EXTS:\n+                            last_msg = await bot.send_video(\n+                                chat_id=int_chat_id, video=f, **media_kwargs\n+                            )\n+                        elif ext in _VOICE_EXTS and is_voice:\n+                            last_msg = await bot.send_voice(\n+                                chat_id=int_chat_id, voice=f, **media_kwargs\n+                            )\n+                        elif ext in _TELEGRAM_SEND_AUDIO_EXTS:\n+                            last_msg = await bot.send_audio(\n+                                chat_id=int_chat_id, audio=f, **media_kwargs\n+                            )\n+                        else:\n+                            last_msg = await bot.send_document(\n+                                chat_id=int_chat_id, document=f, **media_kwargs\n+                            )\n+                    except Exception as media_err:\n+                        if _is_telegram_thread_not_found(media_err) and media_kwargs.get(\"message_thread_id\"):\n+                            # Thread not found for media — retry without\n+                            # message_thread_id (issue #27012).\n+                            logger.warning(\n+                                \"Thread %s not found for media send, retrying without message_thread_id\",\n+                                media_kwargs[\"message_thread_id\"],\n+                            )\n+                            # Re-seek the file since the first attempt consumed it\n+                            f.seek(0)\n+                            media_kwargs.pop(\"message_thread_id\", None)\n+                            if ext in _IMAGE_EXTS and not force_document:\n+                                last_msg = await bot.send_photo(\n+                                    chat_id=int_chat_id, photo=f, **media_kwargs\n+                                )\n+                            elif ext in _VIDEO_EXTS:\n+                                last_msg = await bot.send_video(\n+                                    chat_id=int_chat_id, video=f, **media_kwargs\n+                                )\n+                            elif ext in _VOICE_EXTS and is_voice:\n+                                last_msg = await bot.send_voice(\n+                                    chat_id=int_chat_id, voice=f, **media_kwargs\n+                                )\n+                            elif ext in _TELEGRAM_SEND_AUDIO_EXTS:\n+                                last_msg = await bot.send_audio(\n+                                    chat_id=int_chat_id, audio=f, **media_kwargs\n+                                )\n+                            else:\n+                                last_msg = await bot.send_document(\n+                                    chat_id=int_chat_id, document=f, **media_kwargs\n+                                )\n+                        else:\n+                            raise\n             except Exception as e:\n                 warning = _sanitize_error_text(f\"Failed to send media {media_path}: {e}\")\n                 logger.error(warning)\n"]}]},{"kind":"pr","number":42051,"title":"fix(cron): centralize Telegram private topic delivery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3051a1634","number":0},{"kind":"pr","value":"#22410","number":22410},{"kind":"pr","value":"#22773","number":22773},{"kind":"pr","value":"#50023","number":50023}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":48836,"title":"Fix Telegram cron delivery into DM topics","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fc31f14cdaa1471181956d78bb9d7727bf22bde5","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":50023,"title":"fix(cron): route Telegram DM-topic cron delivery through DeliveryRouter (#22773)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","scripts/release.py","tests/cron/test_scheduler.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -847,16 +847,74 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n         delivered = False\n         target_errors = []\n         if runtime_adapter is not None and loop is not None and getattr(loop, \"is_running\", lambda: False)():\n-            send_metadata = {\"thread_id\": thread_id} if thread_id else None\n+            # Telegram three-mode topic routing (#22773): a private chat\n+            # (positive chat_id) with a NUMERIC topic id is a Bot API Direct\n+            # Messages topic and must be addressed via ``direct_messages_topic_id``\n+            # — a bare ``message_thread_id`` is rejected/mis-routed by Bot API\n+            # 10.0 and lands in General.  Forum/supergroup targets (negative\n+            # chat_id) and named DM-topic lanes keep the default thread_id\n+            # handling.  Compute the routed metadata ONCE so both the text send\n+            # (via DeliveryRouter) and the media send use the same routing.\n+            from gateway.delivery import (\n+                DeliveryRouter,\n+                DeliveryTarget,\n+                _looks_like_int,\n+                _looks_like_telegram_private_chat_id,\n+            )\n+\n+            is_private_dm_topic = (\n+                platform == Platform.TELEGRAM\n+                and thread_id is not None\n+                and _looks_like_telegram_private_chat_id(str(chat_id))\n+                and _looks_like_int(str(thread_id))\n+            )\n+            if is_private_dm_topic:\n+                # Routed via direct_messages_topic_id (mode 2), no bare thread_id.\n+                route_thread_id = None\n+                route_metadata = {\n+                    \"direct_messages_topic_id\": str(thread_id),\n+                    \"job_id\": job[\"id\"],\n+                }\n+                # Media metadata mirrors the text routing so attachments land in\n+                # the same DM topic instead of the General lane (#22773).\n+                media_metadata = {\"direct_messages_topic_id\": str(thread_id)}\n+            else:\n+                route_thread_id = str(thread_id) if thread_id is not None else None\n+                route_metadata = {\"job_id\": job[\"id\"]}\n+                media_metadata = {\"thread_id\": thread_id} if thread_id else None\n+\n             try:\n-                # Send cleaned text (MEDIA tags stripped) — not the raw content\n+                # Send cleaned text (MEDIA tags stripped) — not the raw content.\n+                # Route through the gateway's DeliveryRouter so the live send\n+                # gets the same platform-specific routing as live messages —\n+                # in particular Telegram's three-mode topic routing.  The\n+                # standalone cron path lacked this, so DM-topic cron deliveries\n+                # landed in the General topic or were rejected by Bot API 10.0\n+                # (#22773).\n                 text_to_send = cleaned_delivery_content.strip()\n                 adapter_ok = True\n                 timed_out = False\n                 if text_to_send:\n                     from agent.async_utils import safe_schedule_threadsafe\n+\n+                    router = DeliveryRouter(config, adapters)\n+                    route_target = DeliveryTarget(\n+                        platform=platform,\n+                        chat_id=str(chat_id),\n+                        thread_id=route_thread_id,\n+                        is_explicit=True,\n+                    )\n+                    # Pass thread routing via the target (not a bare metadata\n+                    # \"thread_id\"): the router only applies its Telegram DM-topic\n+                    # detection when \"thread_id\"/\"message_thread_id\" are absent\n+                    # from metadata, deriving the routing from target.thread_id\n+                    # or the explicit direct_messages_topic_id above.\n                     future = safe_schedule_threadsafe(\n-                        runtime_adapter.send(chat_id, text_to_send, metadata=send_metadata),\n+                        router._deliver_to_platform(\n+                            route_target,\n+                            text_to_send,\n+                            route_metadata,\n+                        ),\n                         loop,\n                     )\n                     if future is None:","@@ -922,54 +980,69 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                             # dispatched).  send_result is None, so skip the\n                             # confirmation/thread-fallback inspection below.\n                             pass\n-                        elif not _confirm_adapter_delivery(send_result):\n-                            # A ``None`` return or a result object missing an\n-                            # explicit ``success`` attribute is NOT a confirmed\n-                            # delivery (#47056): the scheduler would log\n-                            # \"delivered\" while the gateway never saw it.  Fall\n-                            # through to the standalone path.\n-                            err = (\n-                                getattr(send_result, \"error\", None)\n-                                if send_result is not None\n-                                else \"no response from adapter\"\n-                            )\n-                            shape = type(send_result).__name__ if send_result is not None else \"None\"\n-                            msg = (\n-                                f\"live adapter send to {platform_name}:{chat_id} \"\n-                                f\"returned unconfirmed result ({shape}, error={err})\"\n-                            )\n-                            logger.warning(\n-                                \"Job '%s': %s, falling back to standalone\",\n-                                job[\"id\"], msg,\n-                            )\n-                            target_errors.append(msg)\n-                            adapter_ok = False  # fall through to standalone path\n-                        elif (\n-                            send_result\n-                            and thread_id\n-                            and getattr(send_result, \"raw_response\", None)\n-                            and send_result.raw_response.get(\"thread_fallback\")\n-                        ):\n-                            requested_thread_id = send_result.raw_response.get(\"requested_thread_id\") or thread_id\n-                            msg = (\n-                                f\"configured thread_id {requested_thread_id} for \"\n-                                f\"{platform_name}:{chat_id} was not found; delivered without thread_id\"\n-                            )\n-                            logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n-                            delivery_errors.append(msg)\n-\n-                # Send extracted media files as native attachments via the live adapter.\n-                # Skip on an in-flight confirmation timeout: the gateway loop is\n-                # contended, so each media send would also block its 30s budget,\n-                # and the text payload is already assumed delivered (#38922).\n-                # Record the skipped attachments so the drop is visible in the\n-                # job's delivery error rather than silently lost.\n+                        else:\n+                            # _deliver_to_platform returns either a SendResult\n+                            # (.success attr) or, when the silence-narration\n+                            # filter drops the message, a plain dict\n+                            # {\"success\": True, \"delivered\": False, ...}.\n+                            # Normalize both shapes so a getattr default doesn't\n+                            # misread a dict, and so a None / success-less object\n+                            # is NOT counted as delivered (#47056).\n+                            if isinstance(send_result, dict):\n+                                send_success = bool(send_result.get(\"success\", False))\n+                                send_raw_response = send_result.get(\"raw_response\")\n+                            else:\n+                                send_success = _confirm_adapter_delivery(send_result)\n+                                send_raw_response = getattr(send_result, \"raw_response\", None)\n+\n+                            if not send_success:\n+                                if isinstance(send_result, dict):\n+                                    err = send_result.get(\"error\", \"unknown\")\n+                                    shape = \"dict\"\n+                                elif send_result is not None:\n+                                    err = getattr(send_result, \"error\", None)\n+                                    shape = type(send_result).__name__\n+                                else:\n+                                    err = \"no response from adapter\"\n+                                    shape = \"None\"\n+                                msg = (\n+                                    f\"live adapter send to {platform_name}:{chat_id} \"\n+                                    f\"returned unconfirmed result ({shape}, error={err})\"\n+                                )\n+                                logger.warning(\n+                                    \"Job '%s': %s, falling back to standalone\",\n+                                    job[\"id\"], msg,\n+                                )\n+                                target_errors.append(msg)\n+                                adapter_ok = False  # fall through to standalone path\n+                            elif (\n+                                send_raw_response\n+                                and thread_id\n+                                and send_raw_response.get(\"thread_fallback\")\n+                            ):\n+                                requested_thread_id = send_raw_response.get(\"requested_thread_id\") or thread_id\n+                                msg = (\n+                                    f\"configured thread_id {requested_thread_id} for \"\n+                                    f\"{platform_name}:{chat_id} was not found; delivered without thread_id\"\n+                                )\n+                                logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n+                                delivery_errors.append(msg)\n+\n+                # Send extracted media files as native attachments via the live\n+                # adapter, using the same DM-topic-aware routing as the text send\n+                # (#22773 — media previously used a bare thread_id and landed in\n+                # the General lane for private DM topics).  Skip on an in-flight\n+                # confirmation timeout: the gateway loop is contended, so each\n+                # media send would also block its 30s budget, and the text\n+                # payload is already assumed delivered (#38922).  Record the\n+                # skipped attachments so the drop is visible rather than silently\n+                # lost.\n                 if adapter_ok and not timed_out and media_files:\n                     _send_media_via_adapter(\n                         runtime_adapter,\n                         chat_id,\n                         media_files,\n-                        send_metadata,\n+                        media_metadata,\n                         loop,\n                         job,\n                         platform=platform,"]},{"file":"scripts/release.py","hunks":["@@ -1458,6 +1458,7 @@\n     \"[redacted:email]\": \"nekwo\",  # PR #26481 (PS5.1 UTF-8 BOM)\n     \"[redacted:email]\": \"nekwo\",\n     \"[redacted:email]\": \"stepanov1975\",  # PR #22074 (setup config picker writes)\n+    \"[redacted:email]\": \"devsart95\",  # PR #23249 (cron Telegram DM topic delivery)\n     \"[redacted:email]\": \"flooryyyy\",  # PR #26374 (tool_trace error detection)\n     \"[redacted:email]\": \"dgians\",  # PR #26034 (.ts/.py/.sh docs types)\n     \"[redacted:email]\": \"dgians\",  # PR #26034 (bot-committed by zealy-tzco under dgians' PR)"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -625,9 +625,15 @@ def test_live_adapter_sends_media_as_attachments(self, tmp_path, monkeypatch):\n \n         # run_coroutine_threadsafe returns concurrent.futures.Future (has timeout kwarg)\n         def fake_run_coro(coro, _loop):\n+            # Actually run the routed coroutine (router._deliver_to_platform)\n+            # so the underlying adapter.send is invoked, then wrap the real\n+            # result in a completed Future (matching run_coroutine_threadsafe).\n+            import asyncio as _asyncio\n             future = Future()\n-            future.set_result(MagicMock(success=True))\n-            coro.close()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n             return future\n \n         job = {","@@ -676,9 +682,15 @@ def test_live_adapter_routes_image_to_send_image_file(self, tmp_path, monkeypatc\n         loop.is_running.return_value = True\n \n         def fake_run_coro(coro, _loop):\n+            # Actually run the routed coroutine (router._deliver_to_platform)\n+            # so the underlying adapter.send is invoked, then wrap the real\n+            # result in a completed Future (matching run_coroutine_threadsafe).\n+            import asyncio as _asyncio\n             future = Future()\n-            future.set_result(MagicMock(success=True))\n-            coro.close()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n             return future\n \n         job = {","@@ -719,9 +731,15 @@ def test_live_adapter_media_only_no_text(self, tmp_path, monkeypatch):\n         loop.is_running.return_value = True\n \n         def fake_run_coro(coro, _loop):\n+            # Actually run the routed coroutine (router._deliver_to_platform)\n+            # so the underlying adapter.send is invoked, then wrap the real\n+            # result in a completed Future (matching run_coroutine_threadsafe).\n+            import asyncio as _asyncio\n             future = Future()\n-            future.set_result(MagicMock(success=True))\n-            coro.close()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n             return future\n \n         job = {","@@ -763,9 +781,15 @@ def test_live_adapter_sends_cleaned_text_not_raw(self):\n         loop.is_running.return_value = True\n \n         def fake_run_coro(coro, _loop):\n+            # Actually run the routed coroutine (router._deliver_to_platform)\n+            # so the underlying adapter.send is invoked, then wrap the real\n+            # result in a completed Future (matching run_coroutine_threadsafe).\n+            import asyncio as _asyncio\n             future = Future()\n-            future.set_result(MagicMock(success=True))\n-            coro.close()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n             return future\n \n         job = {","@@ -2889,19 +2913,145 @@ def fake_run_coro(coro, _loop):\n         standalone_send.assert_awaited_once()\n         assert result is None, f\"standalone should have delivered, got: {result!r}\"\n \n-    def test_live_adapter_thread_fallback_records_delivery_error(self):\n-        \"\"\"A cron target with an explicit topic must not be marked clean if\n-        Telegram falls back to the base chat after \"thread not found\".\n+    def test_live_adapter_private_dm_topic_routes_via_direct_messages_topic_id(self):\n+        \"\"\"#22773: a cron target to a PRIVATE Telegram chat with a numeric topic\n+        id must be routed via ``direct_messages_topic_id`` (Bot API DM topics),\n+        NOT a bare ``message_thread_id`` (which Bot API 10.0 rejects / mis-routes\n+        to General).  The cron live-adapter path routes through the gateway\n+        DeliveryRouter, which applies the same three-mode routing as live\n+        messages.\n         \"\"\"\n         from gateway.config import Platform\n         from gateway.platforms.base import SendResult\n         from concurrent.futures import Future\n \n+        send_result = SendResult(success=True, message_id=\"42\")\n+        adapter = MagicMock()\n+        adapter.send = AsyncMock(return_value=send_result)\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+        # DeliveryRouter consults the silence-narration config flag.\n+        mock_cfg.filter_silence_narration = False\n+\n+        loop = MagicMock()\n+        loop.is_running.return_value = True\n+\n+        job = {\n+            \"id\": \"dm-topic-job\",\n+            \"deliver\": \"telegram:226252250:7072\",  # private chat + numeric topic\n+        }\n+\n+        def fake_run_coro(coro, _loop):\n+            import asyncio as _asyncio\n+            future = Future()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n+            return future\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"cron.scheduler.load_config\", return_value={\"cron\": {\"wrap_response\": False}}), \\\n+             patch(\"asyncio.run_coroutine_threadsafe\", side_effect=fake_run_coro):\n+            result = _deliver_result(\n+                job,\n+                \"Hello world\",\n+                adapters={Platform.TELEGRAM: adapter},\n+                loop=loop,\n+            )\n+\n+        assert result is None, f\"expected clean delivery, got: {result!r}\"\n+        adapter.send.assert_called_once()\n+        sent_chat_id, sent_text = adapter.send.call_args[0][0], adapter.send.call_args[0][1]\n+        sent_metadata = adapter.send.call_args[1][\"metadata\"]\n+        assert sent_chat_id == \"226252250\"\n+        assert sent_text == \"Hello world\"\n+        # The topic must be addressed via direct_messages_topic_id, and a bare\n+        # message_thread_id must NOT be set (that is the Bot API 10.0 bug).\n+        assert str(sent_metadata.get(\"direct_messages_topic_id\")) == \"7072\"\n+        assert not sent_metadata.get(\"message_thread_id\")\n+\n+    def test_live_adapter_private_dm_topic_media_routes_via_direct_messages_topic_id(self, tmp_path, monkeypatch):\n+        \"\"\"#22773 (media): MEDIA attachments to a private DM topic must also be\n+        routed via ``direct_messages_topic_id``, not a bare ``message_thread_id``\n+        — the media path previously used the bare thread_id and landed\n+        attachments in the General lane.\"\"\"\n+        from gateway.config import Platform\n+        from gateway.platforms.base import SendResult\n+        from concurrent.futures import Future\n+\n+        media_root = tmp_path / \"media-cache\"\n+        media_file = media_root / \"chart.png\"\n+        media_file.parent.mkdir(parents=True, exist_ok=True)\n+        media_file.write_bytes(b\"media\")\n+        monkeypatch.setattr(\n+            \"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS\",\n+            (media_root,),\n+        )\n+        media_path = media_file.resolve()\n+\n+        adapter = AsyncMock()\n+        adapter.send.return_value = SendResult(success=True, message_id=\"1\")\n+        adapter.send_image_file.return_value = SendResult(success=True, message_id=\"2\")\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+        mock_cfg.filter_silence_narration = False\n+\n+        loop = MagicMock()\n+        loop.is_running.return_value = True\n+\n+        job = {\n+            \"id\": \"dm-topic-media-job\",\n+            \"deliver\": \"telegram:226252250:7072\",  # private chat + numeric topic\n+        }\n+\n+        def fake_run_coro(coro, _loop):\n+            import asyncio as _asyncio\n+            future = Future()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n+            return future\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"cron.scheduler.load_config\", return_value={\"cron\": {\"wrap_response\": False}}), \\\n+             patch(\"asyncio.run_coroutine_threadsafe\", side_effect=fake_run_coro):\n+            _deliver_result(\n+                job,\n+                f\"Chart attached\\nMEDIA:{media_path}\",\n+                adapters={Platform.TELEGRAM: adapter},\n+                loop=loop,\n+            )\n+\n+        adapter.send_image_file.assert_called_once()\n+        media_metadata = adapter.send_image_file.call_args[1][\"metadata\"]\n+        assert str(media_metadata.get(\"direct_messages_topic_id\")) == \"7072\"\n+        assert not media_metadata.get(\"message_thread_id\")\n+        assert not media_metadata.get(\"thread_id\")\n+\n+    def test_live_adapter_forum_thread_fallback_records_delivery_error(self):\n+        \"\"\"A forum/supergroup cron target whose configured topic is gone must\n+        NOT be reported as a clean delivery: when the Telegram adapter falls\n+        back to the base chat (raw_response thread_fallback), the scheduler must\n+        record the \"delivered without thread_id\" delivery error.  Regression\n+        coverage for the thread_fallback-recording branch (kept distinct from\n+        the #22773 routing fix).\"\"\"\n+        from gateway.config import Platform\n+        from gateway.platforms.base import SendResult\n+        from concurrent.futures import Future\n+\n         send_result = SendResult(\n             success=True,\n             message_id=\"42\",\n             raw_response={\n-                \"requested_thread_id\": 7072,\n+                \"requested_thread_id\": 17,\n                 \"thread_fallback\": True,\n             },\n         )","@@ -2912,21 +3062,26 @@ def test_live_adapter_thread_fallback_records_delivery_error(self):\n         pconfig.enabled = True\n         mock_cfg = MagicMock()\n         mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+        mock_cfg.filter_silence_narration = False\n \n         loop = MagicMock()\n         loop.is_running.return_value = True\n \n+        # Forum supergroup (negative chat_id) + numeric topic → mode 1\n+        # (message_thread_id); NOT a private DM topic.\n         job = {\n-            \"id\": \"thread-fallback-job\",\n-            \"deliver\": \"telegram:226252250:7072\",\n+            \"id\": \"forum-fallback-job\",\n+            \"deliver\": \"telegram:-1001234567890:17\",\n         }\n \n-        completed_future = Future()\n-        completed_future.set_result(send_result)\n-\n         def fake_run_coro(coro, _loop):\n-            coro.close()\n-            return completed_future\n+            import asyncio as _asyncio\n+            future = Future()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n+            return future\n \n         with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n              patch(\"cron.scheduler.load_config\", return_value={\"cron\": {\"wrap_response\": False}}), \\","@@ -2938,15 +3093,11 @@ def fake_run_coro(coro, _loop):\n                 loop=loop,\n             )\n \n-        assert result == (\n-            \"configured thread_id 7072 for telegram:226252250 was not found; \"\n-            \"delivered without thread_id\"\n-        )\n-        adapter.send.assert_called_once_with(\n-            \"226252250\",\n-            \"Hello world\",\n-            metadata={\"thread_id\": \"7072\"},\n-        )\n+        assert result is not None\n+        assert \"was not found; delivered without thread_id\" in result\n+        # Forum target routes via message_thread_id (mode 1), not DM-topic.\n+        sent_metadata = adapter.send.call_args[1][\"metadata\"]\n+        assert not sent_metadata.get(\"direct_messages_topic_id\")\n \n \n class TestDeliverResultLiveAdapterUnconfirmed:\n"]}]},{"kind":"pr","number":55270,"title":"fix(cron): use message_thread_id for private-chat forum topics (#55265)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fc31f14cdaa1471181956d78bb9d7727bf22bde5","number":0},{"kind":"pr","value":"#52060","number":52060}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":57892,"title":"fix(telegram): remove early return that blocks direct_messages_topic_id fallback for anchor-less DM topic sends","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4a80e27bba7e42a7bf81a584b7a29b41476221d3","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":58165,"title":"fix(cron): route Telegram forum topics via message_thread_id, support channel DM topics (#52060)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","tests/cron/test_scheduler.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -31,7 +31,7 @@\n     except ImportError:\n         msvcrt = None\n from pathlib import Path\n-from typing import List, Optional\n+from typing import Any, List, Optional\n \n # Add parent directory to path for imports BEFORE repo-level imports.\n # Without this, standalone invocations (e.g. after `hermes update` reloads","@@ -1217,6 +1217,62 @@ def _confirm_adapter_delivery(send_result) -> bool:\n     return bool(getattr(send_result, \"success\"))\n \n \n+def _is_channel_dm_topic(\n+    runtime_adapter: Any,\n+    chat_id: Any,\n+    loop: Any,\n+    job_id: str,\n+) -> bool:\n+    \"\"\"Decide whether an (already-ambiguous) Telegram topic target is a genuine\n+    Bot API *channel* Direct-Messages topic (route via\n+    ``direct_messages_topic_id``) rather than a forum-style topic in a private\n+    chat (route via ``message_thread_id``).\n+\n+    Callers gate this on the ambiguous shape first\n+    (``telegram:<positive_chat_id>:<numeric_thread_id>``) — that shape is\n+    identical for both cases, so shape alone cannot decide (this was the #52060\n+    regression).  The real signal is the chat *type*: a genuine channel DM topic\n+    lives on a ``channel`` chat.  Probe the live adapter's ``get_chat_info`` once\n+    and only return True when the chat is a channel.\n+\n+    Fails SAFE to ``message_thread_id`` (returns False) for adapters without a\n+    probe, or any probe error/timeout — that is the pre-#22773 behaviour and the\n+    correct default for the common forum-topic case.\n+    \"\"\"\n+    # Resolve on the CLASS, not the instance (general pitfall #11): a MagicMock\n+    # instance auto-creates a truthy ``get_chat_info`` attribute, so an\n+    # instance-level probe would misclassify test doubles. Real adapters expose\n+    # the coroutine on the class regardless.\n+    get_chat_info = getattr(type(runtime_adapter), \"get_chat_info\", None)\n+    if not callable(get_chat_info):\n+        return False\n+    try:\n+        from agent.async_utils import safe_schedule_threadsafe\n+\n+        future = safe_schedule_threadsafe(\n+            get_chat_info(runtime_adapter, str(chat_id)), loop,  # type: ignore[arg-type]\n+        )\n+        if future is None:\n+            return False\n+        # Lighter than a send (metadata-only Bot API call), so a shorter bound\n+        # than the 30s/60s send waits elsewhere in this file is intentional.\n+        info = future.result(timeout=10)\n+    except Exception:\n+        logger.debug(\n+            \"Job '%s': get_chat_info probe failed for chat=%s — \"\n+            \"defaulting to message_thread_id routing\",\n+            job_id, chat_id, exc_info=True,\n+        )\n+        return False\n+    is_channel = isinstance(info, dict) and str(info.get(\"type\") or \"\").lower() == \"channel\"\n+    if is_channel:\n+        logger.info(\n+            \"Job '%s': chat=%s is a channel — routing via direct_messages_topic_id\",\n+            job_id, chat_id,\n+        )\n+    return is_channel\n+\n+\n def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Optional[str]:\n     \"\"\"\n     Deliver job output to the configured target(s) (origin chat, specific platform, etc.).","@@ -1440,14 +1496,16 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                 opened_thread_id = new_thread_id\n \n         if runtime_adapter is not None and loop is not None and getattr(loop, \"is_running\", lambda: False)():\n-            # Telegram three-mode topic routing (#22773): a private chat\n-            # (positive chat_id) with a NUMERIC topic id is a Bot API Direct\n-            # Messages topic and must be addressed via ``direct_messages_topic_id``\n-            # — a bare ``message_thread_id`` is rejected/mis-routed by Bot API\n-            # 10.0 and lands in General.  Forum/supergroup targets (negative\n-            # chat_id) and named DM-topic lanes keep the default thread_id\n-            # handling.  Compute the routed metadata ONCE so both the text send\n-            # (via DeliveryRouter) and the media send use the same routing.\n+            # Telegram topic routing (#22773, regression fixed #52060): a\n+            # ``telegram:<positive_chat_id>:<numeric_thread_id>`` cron target is\n+            # ambiguous — a forum-style topic in a private chat and a genuine\n+            # Bot API channel Direct-Messages topic share the same shape and\n+            # need OPPOSITE routing. Disambiguate at delivery time via\n+            # ``_is_channel_dm_topic`` (see its docstring for the full\n+            # rationale); ``thread_id`` goes in ``route_metadata`` so the\n+            # anchorless cron send bypasses the DeliveryRouter's private-chat\n+            # reply-anchor requirement. Compute the routed metadata ONCE so both\n+            # the text send (via DeliveryRouter) and the media send agree.\n             from gateway.delivery import (\n                 DeliveryRouter,\n                 DeliveryTarget,","@@ -1455,14 +1513,18 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                 looks_like_telegram_private_chat_id,\n             )\n \n-            is_private_dm_topic = (\n+            is_ambiguous_telegram_topic = (\n                 platform == Platform.TELEGRAM\n                 and thread_id is not None\n                 and looks_like_telegram_private_chat_id(str(chat_id))\n                 and _looks_like_int(str(thread_id))\n             )\n-            if is_private_dm_topic:\n-                # Routed via direct_messages_topic_id (mode 2), no bare thread_id.\n+            route_via_dm_topic = is_ambiguous_telegram_topic and _is_channel_dm_topic(\n+                runtime_adapter, chat_id, loop, job[\"id\"],\n+            )\n+            if route_via_dm_topic:\n+                # Genuine Bot API channel Direct-Messages topic (#22773 mode 2):\n+                # routed via direct_messages_topic_id, no bare thread_id.\n                 route_thread_id = None\n                 route_metadata = {\n                     \"direct_messages_topic_id\": str(thread_id),","@@ -1472,8 +1534,18 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n                 # the same DM topic instead of the General lane (#22773).\n                 media_metadata = {\"direct_messages_topic_id\": str(thread_id)}\n             else:\n+                # Forum-style topic (private chat / supergroup) or non-topic\n+                # target: route via message_thread_id (#52060).  Put thread_id in\n+                # *route_metadata* (not just the DeliveryTarget) deliberately —\n+                # the DeliveryRouter's private-chat topic detection\n+                # (gateway/delivery.py) demands a reply anchor when thread_id is\n+                # absent from metadata; cron deliveries have no inbound reply\n+                # anchor, so the metadata key bypasses that check and lets the\n+                # adapter route via a plain message_thread_id.\n                 route_thread_id = str(thread_id) if thread_id is not None else None\n                 route_metadata = {\"job_id\": job[\"id\"]}\n+                if route_thread_id:\n+                    route_metadata[\"thread_id\"] = route_thread_id\n                 media_metadata = {\"thread_id\": thread_id} if thread_id else None\n \n             try:"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -3296,35 +3296,40 @@ def fake_run_coro(coro, _loop):\n         standalone_send.assert_awaited_once()\n         assert result is None, f\"standalone should have delivered, got: {result!r}\"\n \n-    def test_live_adapter_private_dm_topic_routes_via_direct_messages_topic_id(self):\n-        \"\"\"#22773: a cron target to a PRIVATE Telegram chat with a numeric topic\n-        id must be routed via ``direct_messages_topic_id`` (Bot API DM topics),\n-        NOT a bare ``message_thread_id`` (which Bot API 10.0 rejects / mis-routes\n-        to General).  The cron live-adapter path routes through the gateway\n-        DeliveryRouter, which applies the same three-mode routing as live\n-        messages.\n+    def test_live_adapter_forum_topic_in_private_chat_routes_via_message_thread_id(self):\n+        \"\"\"#52060: a cron target to a PRIVATE Telegram chat with a numeric topic\n+        id is a normal forum-style topic — it must route via ``message_thread_id``,\n+        NOT ``direct_messages_topic_id``.  The #22773 heuristic inferred a Bot API\n+        channel DM topic from positive chat_id + numeric thread and nulled\n+        ``message_thread_id``, so deliveries landed in General.  We now probe the\n+        live adapter's ``get_chat_info``; a non-channel chat routes via\n+        ``message_thread_id``.\n         \"\"\"\n         from gateway.config import Platform\n         from gateway.platforms.base import SendResult\n         from concurrent.futures import Future\n \n         send_result = SendResult(success=True, message_id=\"42\")\n-        adapter = MagicMock()\n+\n+        class _ForumAdapter(MagicMock):\n+            async def get_chat_info(self, chat_id):\n+                return {\"name\": \"Proyectos\", \"type\": \"forum\", \"is_forum\": True}\n+\n+        adapter = _ForumAdapter()\n         adapter.send = AsyncMock(return_value=send_result)\n \n         pconfig = MagicMock()\n         pconfig.enabled = True\n         mock_cfg = MagicMock()\n         mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n-        # DeliveryRouter consults the silence-narration config flag.\n         mock_cfg.filter_silence_narration = False\n \n         loop = MagicMock()\n         loop.is_running.return_value = True\n \n         job = {\n-            \"id\": \"dm-topic-job\",\n-            \"deliver\": \"telegram:226252250:7072\",  # private chat + numeric topic\n+            \"id\": \"forum-topic-job\",\n+            \"deliver\": \"telegram:226252250:7072\",  # private chat + numeric forum topic\n         }\n \n         def fake_run_coro(coro, _loop):","@@ -3352,16 +3357,256 @@ def fake_run_coro(coro, _loop):\n         sent_metadata = adapter.send.call_args[1][\"metadata\"]\n         assert sent_chat_id == \"226252250\"\n         assert sent_text == \"Hello world\"\n-        # The topic must be addressed via direct_messages_topic_id, and a bare\n-        # message_thread_id must NOT be set (that is the Bot API 10.0 bug).\n+        # Forum topics route via message_thread_id (thread_id in metadata), NOT\n+        # direct_messages_topic_id.\n+        assert not sent_metadata.get(\"direct_messages_topic_id\")\n+        assert str(sent_metadata.get(\"thread_id\")) == \"7072\"\n+\n+    def test_live_adapter_ambiguous_topic_probe_failure_falls_back_to_message_thread_id(self):\n+        \"\"\"Fail SAFE: when the ``get_chat_info`` probe cannot resolve the chat\n+        type (adapter with no usable probe / raising probe), an ambiguous\n+        private-chat topic target defaults to ``message_thread_id`` — the common\n+        forum-topic case and pre-#22773 behaviour, never the DM-topic route.\n+        \"\"\"\n+        from gateway.config import Platform\n+        from gateway.platforms.base import SendResult\n+        from concurrent.futures import Future\n+\n+        send_result = SendResult(success=True, message_id=\"42\")\n+\n+        # Plain MagicMock: its auto-created get_chat_info returns a MagicMock,\n+        # not an awaitable, so the scheduled coroutine raises and the probe\n+        # fails closed to message_thread_id.\n+        adapter = MagicMock()\n+        adapter.send = AsyncMock(return_value=send_result)\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+        mock_cfg.filter_silence_narration = False\n+\n+        loop = MagicMock()\n+        loop.is_running.return_value = True\n+\n+        job = {\n+            \"id\": \"probe-fail-job\",\n+            \"deliver\": \"telegram:226252250:7072\",\n+        }\n+\n+        def fake_run_coro(coro, _loop):\n+            import asyncio as _asyncio\n+            future = Future()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n+            return future\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"cron.scheduler.load_config\", return_value={\"cron\": {\"wrap_response\": False}}), \\\n+             patch(\"asyncio.run_coroutine_threadsafe\", side_effect=fake_run_coro):\n+            result = _deliver_result(\n+                job,\n+                \"Hello world\",\n+                adapters={Platform.TELEGRAM: adapter},\n+                loop=loop,\n+            )\n+\n+        assert result is None, f\"expected clean delivery, got: {result!r}\"\n+        adapter.send.assert_called_once()\n+        sent_metadata = adapter.send.call_args[1][\"metadata\"]\n+        assert not sent_metadata.get(\"direct_messages_topic_id\")\n+        assert str(sent_metadata.get(\"thread_id\")) == \"7072\"\n+\n+    def test_live_adapter_probe_returns_none_falls_back_to_message_thread_id(self):\n+        \"\"\"Fail SAFE when the probe yields a non-dict result. A relay/proxy\n+        adapter (or a future ``get_chat_info`` variant) may return ``None``\n+        rather than a dict; the ``isinstance(info, dict)`` guard must still route\n+        via ``message_thread_id``, distinct from the raising-probe path.\n+\n+        (The real Telegram adapter returns a dict on every path — a\n+        ``type=\"dm\"`` dict with an ``error`` key on failure, covered separately\n+        by ``..._adapter_error_dict_falls_back...`` — never ``None``. This test\n+        locks the non-dict defensive branch for other adapters.)\"\"\"\n+        from gateway.config import Platform\n+        from gateway.platforms.base import SendResult\n+        from concurrent.futures import Future\n+\n+        send_result = SendResult(success=True, message_id=\"42\")\n+\n+        class _NoneProbeAdapter(MagicMock):\n+            async def get_chat_info(self, chat_id):\n+                return None\n+\n+        adapter = _NoneProbeAdapter()\n+        adapter.send = AsyncMock(return_value=send_result)\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+        mock_cfg.filter_silence_narration = False\n+\n+        loop = MagicMock()\n+        loop.is_running.return_value = True\n+\n+        job = {\n+            \"id\": \"none-probe-job\",\n+            \"deliver\": \"telegram:226252250:7072\",\n+        }\n+\n+        def fake_run_coro(coro, _loop):\n+            import asyncio as _asyncio\n+            future = Future()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n+            return future\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"cron.scheduler.load_config\", return_value={\"cron\": {\"wrap_response\": False}}), \\\n+             patch(\"asyncio.run_coroutine_threadsafe\", side_effect=fake_run_coro):\n+            result = _deliver_result(\n+                job,\n+                \"Hello world\",\n+                adapters={Platform.TELEGRAM: adapter},\n+                loop=loop,\n+            )\n+\n+        assert result is None, f\"expected clean delivery, got: {result!r}\"\n+        adapter.send.assert_called_once()\n+        sent_metadata = adapter.send.call_args[1][\"metadata\"]\n+        assert not sent_metadata.get(\"direct_messages_topic_id\")\n+        assert str(sent_metadata.get(\"thread_id\")) == \"7072\"\n+\n+    def test_live_adapter_error_dict_falls_back_to_message_thread_id(self):\n+        \"\"\"Fail SAFE on the REAL Telegram adapter error contract: on a failed\n+        ``get_chat.get_chat`` the adapter returns ``{\"type\": \"dm\", \"error\": ...}``\n+        (plugins/platforms/telegram/adapter.py::get_chat_info), NOT ``None`` and\n+        NOT a raise. A ``type=\"dm\"`` (or bot-missing ``{\"type\": \"dm\"}``) result\n+        must route via ``message_thread_id`` — only a genuine ``type=\"channel\"``\n+        gets ``direct_messages_topic_id``. This locks the exact dict shape\n+        production emits so a forum-topic cron never mis-routes to General.\"\"\"\n+        from gateway.config import Platform\n+        from gateway.platforms.base import SendResult\n+        from concurrent.futures import Future\n+\n+        send_result = SendResult(success=True, message_id=\"42\")\n+\n+        class _ErrorDictAdapter(MagicMock):\n+            async def get_chat_info(self, chat_id):\n+                # Mirrors the real adapter's except-branch return shape.\n+                return {\"name\": str(chat_id), \"type\": \"dm\", \"error\": \"Chat not found\"}\n+\n+        adapter = _ErrorDictAdapter()\n+        adapter.send = AsyncMock(return_value=send_result)\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+        mock_cfg.filter_silence_narration = False\n+\n+        loop = MagicMock()\n+        loop.is_running.return_value = True\n+\n+        job = {\n+            \"id\": \"error-dict-job\",\n+            \"deliver\": \"telegram:226252250:7072\",\n+        }\n+\n+        def fake_run_coro(coro, _loop):\n+            import asyncio as _asyncio\n+            future = Future()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n+            return future\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"cron.scheduler.load_config\", return_value={\"cron\": {\"wrap_response\": False}}), \\\n+             patch(\"asyncio.run_coroutine_threadsafe\", side_effect=fake_run_coro):\n+            result = _deliver_result(\n+                job,\n+                \"Hello world\",\n+                adapters={Platform.TELEGRAM: adapter},\n+                loop=loop,\n+            )\n+\n+        assert result is None, f\"expected clean delivery, got: {result!r}\"\n+        adapter.send.assert_called_once()\n+        sent_metadata = adapter.send.call_args[1][\"metadata\"]\n+        assert not sent_metadata.get(\"direct_messages_topic_id\")\n+        assert str(sent_metadata.get(\"thread_id\")) == \"7072\"\n+\n+    def test_live_adapter_channel_dm_topic_routes_via_direct_messages_topic_id(self):\n+        \"\"\"#22773 (done right): a genuine Bot API 10.0 *channel* Direct-Messages\n+        topic must be routed via ``direct_messages_topic_id`` (a bare\n+        ``message_thread_id`` is rejected / mis-routed there).  We recognise it\n+        from the real runtime signal — ``get_chat_info`` reports the chat as a\n+        ``channel`` — not from a positive-chat-id + numeric-thread guess.\n+        \"\"\"\n+        from gateway.config import Platform\n+        from gateway.platforms.base import SendResult\n+        from concurrent.futures import Future\n+\n+        send_result = SendResult(success=True, message_id=\"42\")\n+\n+        class _ChannelAdapter(MagicMock):\n+            async def get_chat_info(self, chat_id):\n+                return {\"name\": \"My Channel\", \"type\": \"channel\", \"is_forum\": False}\n+\n+        adapter = _ChannelAdapter()\n+        adapter.send = AsyncMock(return_value=send_result)\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+        mock_cfg.filter_silence_narration = False\n+\n+        loop = MagicMock()\n+        loop.is_running.return_value = True\n+\n+        job = {\n+            \"id\": \"channel-dm-topic-job\",\n+            \"deliver\": \"telegram:226252250:7072\",\n+        }\n+\n+        def fake_run_coro(coro, _loop):\n+            import asyncio as _asyncio\n+            future = Future()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n+            return future\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"cron.scheduler.load_config\", return_value={\"cron\": {\"wrap_response\": False}}), \\\n+             patch(\"asyncio.run_coroutine_threadsafe\", side_effect=fake_run_coro):\n+            result = _deliver_result(\n+                job,\n+                \"Hello world\",\n+                adapters={Platform.TELEGRAM: adapter},\n+                loop=loop,\n+            )\n+\n+        assert result is None, f\"expected clean delivery, got: {result!r}\"\n+        adapter.send.assert_called_once()\n+        sent_metadata = adapter.send.call_args[1][\"metadata\"]\n+        # Genuine channel DM topic routes via direct_messages_topic_id, no bare\n+        # message_thread_id.\n         assert str(sent_metadata.get(\"direct_messages_topic_id\")) == \"7072\"\n         assert not sent_metadata.get(\"message_thread_id\")\n \n-    def test_live_adapter_private_dm_topic_media_routes_via_direct_messages_topic_id(self, tmp_path, monkeypatch):\n-        \"\"\"#22773 (media): MEDIA attachments to a private DM topic must also be\n-        routed via ``direct_messages_topic_id``, not a bare ``message_thread_id``\n-        — the media path previously used the bare thread_id and landed\n-        attachments in the General lane.\"\"\"\n+    def test_live_adapter_forum_topic_media_routes_via_message_thread_id(self, tmp_path, monkeypatch):\n+        \"\"\"#52060 (media): MEDIA attachments to a forum-style topic in a private\n+        chat must also route via ``thread_id`` (message_thread_id), not\n+        ``direct_messages_topic_id``.\"\"\"\n         from gateway.config import Platform\n         from gateway.platforms.base import SendResult\n         from concurrent.futures import Future","@@ -3376,7 +3621,80 @@ def test_live_adapter_private_dm_topic_media_routes_via_direct_messages_topic_id\n         )\n         media_path = media_file.resolve()\n \n-        adapter = AsyncMock()\n+        probe_calls = {\"n\": 0}\n+\n+        class _ForumAdapter(AsyncMock):\n+            async def get_chat_info(self, chat_id):\n+                probe_calls[\"n\"] += 1\n+                return {\"name\": \"Proyectos\", \"type\": \"forum\", \"is_forum\": True}\n+\n+        adapter = _ForumAdapter()\n+        adapter.send.return_value = SendResult(success=True, message_id=\"1\")\n+        adapter.send_image_file.return_value = SendResult(success=True, message_id=\"2\")\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+        mock_cfg.filter_silence_narration = False\n+\n+        loop = MagicMock()\n+        loop.is_running.return_value = True\n+\n+        job = {\n+            \"id\": \"forum-topic-media-job\",\n+            \"deliver\": \"telegram:226252250:7072\",  # private chat + numeric forum topic\n+        }\n+\n+        def fake_run_coro(coro, _loop):\n+            import asyncio as _asyncio\n+            future = Future()\n+            try:\n+                future.set_result(_asyncio.run(coro))\n+            except BaseException as _e:  # noqa: BLE001\n+                future.set_exception(_e)\n+            return future\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"cron.scheduler.load_config\", return_value={\"cron\": {\"wrap_response\": False}}), \\\n+             patch(\"asyncio.run_coroutine_threadsafe\", side_effect=fake_run_coro):\n+            _deliver_result(\n+                job,\n+                f\"Chart attached\\nMEDIA:{media_path}\",\n+                adapters={Platform.TELEGRAM: adapter},\n+                loop=loop,\n+            )\n+\n+        adapter.send_image_file.assert_called_once()\n+        media_metadata = adapter.send_image_file.call_args[1][\"metadata\"]\n+        assert str(media_metadata.get(\"thread_id\")) == \"7072\"\n+        assert not media_metadata.get(\"direct_messages_topic_id\")\n+        # Probe exactly once and reuse the result for BOTH the text and media\n+        # sends — never re-probe per send (the \"compute ONCE\" contract).\n+        assert probe_calls[\"n\"] == 1\n+\n+    def test_live_adapter_channel_dm_topic_media_routes_via_direct_messages_topic_id(self, tmp_path, monkeypatch):\n+        \"\"\"#22773 (media, done right): MEDIA attachments to a genuine channel DM\n+        topic must route via ``direct_messages_topic_id``.\"\"\"\n+        from gateway.config import Platform\n+        from gateway.platforms.base import SendResult\n+        from concurrent.futures import Future\n+\n+        media_root = tmp_path / \"media-cache\"\n+        media_file = media_root / \"chart.png\"\n+        media_file.parent.mkdir(parents=True, exist_ok=True)\n+        media_file.write_bytes(b\"media\")\n+        monkeypatch.setattr(\n+            \"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS\",\n+            (media_root,),\n+        )\n+        media_path = media_file.resolve()\n+\n+        class _ChannelAdapter(AsyncMock):\n+            async def get_chat_info(self, chat_id):\n+                return {\"name\": \"My Channel\", \"type\": \"channel\", \"is_forum\": False}\n+\n+        adapter = _ChannelAdapter()\n         adapter.send.return_value = SendResult(success=True, message_id=\"1\")\n         adapter.send_image_file.return_value = SendResult(success=True, message_id=\"2\")\n ","@@ -3390,8 +3708,8 @@ def test_live_adapter_private_dm_topic_media_routes_via_direct_messages_topic_id\n         loop.is_running.return_value = True\n \n         job = {\n-            \"id\": \"dm-topic-media-job\",\n-            \"deliver\": \"telegram:226252250:7072\",  # private chat + numeric topic\n+            \"id\": \"channel-dm-topic-media-job\",\n+            \"deliver\": \"telegram:226252250:7072\",\n         }\n \n         def fake_run_coro(coro, _loop):\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}