{"repo":"NousResearch/hermes-agent","public_id":"32973","display_label":"Telegram DM topic routing fix for text batches","summary":"These issues and PRs address a bug where Telegram DM text batch messages (including tool progress) are routed using a stale thread_id before DM topic recovery is applied, causing them to appear in the wrong topic. The work involves applying DM topic recovery earlier, before session keying occurs.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34342,"merged_at":"2026-05-29T04:18:40+00:00"},"resolution":{"state":"resolved","by":"pr-34342"},"freshness":{"state":"verified","last_verified_at":"2026-05-29T04:18:40+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":32973,"pr_number":34342,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-29T04:18:40+00:00"}],"members":[{"kind":"issue","number":32973,"title":"[Bug]: Telegram DM text batch (tool progress) routed using pre-recovery thread_id — appears in wrong topic","url":null,"state":"closed"},{"kind":"pr","number":32998,"title":"Fix Telegram DM topic text batch routing","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"100536134cd9eb798f69fc9e928a604062990f8d","number":0},{"kind":"commit","value":"5407d25599e55ba5d4c5d12f9dca793cfb6220a6","number":0},{"kind":"pr","value":"#32973","number":32973},{"kind":"pr","value":"#34342","number":34342}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":34342,"title":"fix(telegram): apply DM topic recovery before session keying","url":null,"state":"merged","close_reason":{"category":"merged"},"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_telegram_text_batching.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -472,6 +472,7 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non\n     return False\n \n \n+import dataclasses\n from dataclasses import dataclass, field\n from datetime import datetime\n from pathlib import Path","@@ -1561,6 +1562,10 @@ def __init__(self, config: PlatformConfig, platform: Platform):\n         self.config = config\n         self.platform = platform\n         self._message_handler: Optional[MessageHandler] = None\n+        # Optional hook (e.g. Telegram DM topic recovery) that rewrites\n+        # ``event.source.thread_id`` before session keying. Returns the\n+        # corrected thread_id or None to leave the source untouched.\n+        self._topic_recovery_fn: Optional[Callable[[Any], Optional[str]]] = None\n         self._running = False\n         self._fatal_error_code: Optional[str] = None\n         self._fatal_error_message: Optional[str] = None","@@ -1816,6 +1821,40 @@ def set_message_handler(self, handler: MessageHandler) -> None:\n         \"\"\"\n         self._message_handler = handler\n \n+    def set_topic_recovery_fn(\n+        self,\n+        fn: Optional[Callable[[Any], Optional[str]]],\n+    ) -> None:\n+        \"\"\"Install a thread_id-recovery hook (Telegram DM topic mode).\n+\n+        The hook is called with ``event.source`` before session keying;\n+        a non-None return value replaces ``source.thread_id``. Pass\n+        ``None`` to clear the hook.\n+        \"\"\"\n+        # Guard against subclasses that initialize via ``object.__new__`` in\n+        # tests and never run ``BasePlatformAdapter.__init__``.\n+        self._topic_recovery_fn = fn  # type: ignore[attr-defined]\n+\n+    def _apply_topic_recovery(self, event: MessageEvent) -> None:\n+        \"\"\"Rewrite ``event.source.thread_id`` in place if the hook returns one.\"\"\"\n+        recover = getattr(self, \"_topic_recovery_fn\", None)\n+        if recover is None:\n+            return\n+        source = getattr(event, \"source\", None)\n+        if source is None:\n+            return\n+        try:\n+            recovered = recover(source)\n+        except Exception:\n+            logger.debug(\"topic recovery hook failed\", exc_info=True)\n+            return\n+        if recovered is None or str(recovered) == str(source.thread_id or \"\"):\n+            return\n+        try:\n+            event.source = dataclasses.replace(source, thread_id=str(recovered))\n+        except Exception:\n+            logger.debug(\"topic recovery rewrite failed\", exc_info=True)\n+\n     def set_busy_session_handler(self, handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]]) -> None:\n         \"\"\"Set an optional handler for messages arriving during active sessions.\"\"\"\n         self._busy_session_handler = handler","@@ -3332,7 +3371,12 @@ async def handle_message(self, event: MessageEvent) -> None:\n             return\n \n         coerce_plaintext_gateway_command(event)\n-        \n+\n+        # Rewrite ``event.source.thread_id`` via the installed recovery hook\n+        # (Telegram DM topic mode) so the session key, guard checks, and\n+        # downstream delivery all agree on the same lane.\n+        self._apply_topic_recovery(event)\n+\n         session_key = build_session_key(\n             event.source,\n             group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),"]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -5027,8 +5027,14 @@ async def _handle_location_message(self, update: Update, context: ContextTypes.D\n     # ------------------------------------------------------------------\n \n     def _text_batch_key(self, event: MessageEvent) -> str:\n-        \"\"\"Session-scoped key for text message batching.\"\"\"\n+        \"\"\"Session-scoped key for text message batching.\n+\n+        Applies the installed topic-recovery hook first so DM-topic batches\n+        coalesce on (and dispatch to) the recovered lane rather than the\n+        raw inbound ``message_thread_id`` Telegram may have attached.\n+        \"\"\"\n         from gateway.session import build_session_key\n+        self._apply_topic_recovery(event)\n         return build_session_key(\n             event.source,\n             group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),"]},{"file":"gateway/run.py","hunks":["@@ -4159,6 +4159,7 @@ async def start(self) -> bool:\n             adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)\n             adapter.set_session_store(self.session_store)\n             adapter.set_busy_session_handler(self._handle_active_session_busy_message)\n+            adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)\n             adapter._busy_text_mode = self._busy_text_mode\n             \n             # Try to connect","@@ -5864,6 +5865,7 @@ async def _platform_reconnect_watcher(self) -> None:\n                     adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)\n                     adapter.set_session_store(self.session_store)\n                     adapter.set_busy_session_handler(self._handle_active_session_busy_message)\n+                    adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)\n                     adapter._busy_text_mode = self._busy_text_mode\n \n                     success = await self._connect_adapter_with_timeout(adapter, platform)"]},{"file":"tests/gateway/test_telegram_text_batching.py","hunks":["@@ -6,12 +6,14 @@\n \"\"\"\n \n import asyncio\n+from types import SimpleNamespace\n from unittest.mock import AsyncMock, MagicMock, patch\n \n import pytest\n \n from gateway.config import Platform, PlatformConfig\n from gateway.platforms.base import MessageEvent, MessageType, SessionSource\n+from gateway.session import build_session_key\n \n \n def _make_adapter():","@@ -119,3 +121,46 @@ async def test_batch_cleans_up_after_flush(self):\n \n         assert len(adapter._pending_text_batches) == 0\n         assert len(adapter._pending_text_batch_tasks) == 0\n+\n+    @pytest.mark.asyncio\n+    async def test_dm_topic_batching_recovers_thread_before_keying(self):\n+        \"\"\"DM-topic text batches should use the recovered topic lane.\"\"\"\n+        adapter = _make_adapter()\n+        adapter.set_topic_recovery_fn(\n+            lambda source: \"222\" if str(source.thread_id or \"\") == \"1\" else None\n+        )\n+        event = MessageEvent(\n+            text=\"hello from DM topic\",\n+            message_type=MessageType.TEXT,\n+            source=SessionSource(\n+                platform=Platform.TELEGRAM,\n+                chat_id=\"12345\",\n+                chat_type=\"dm\",\n+                user_id=\"user-1\",\n+                thread_id=\"1\",\n+            ),\n+        )\n+\n+        adapter._enqueue_text_event(event)\n+\n+        def _key(thread_id: str) -> str:\n+            return build_session_key(\n+                SimpleNamespace(\n+                    platform=Platform.TELEGRAM,\n+                    chat_id=\"12345\",\n+                    chat_type=\"dm\",\n+                    thread_id=thread_id,\n+                ),\n+                group_sessions_per_user=True,\n+                thread_sessions_per_user=False,\n+            )\n+\n+        assert _key(\"222\") in adapter._pending_text_batches\n+        assert _key(\"1\") not in adapter._pending_text_batches\n+        assert event.source.thread_id == \"222\"\n+\n+        await asyncio.sleep(0.2)\n+\n+        adapter.handle_message.assert_called_once()\n+        dispatched = adapter.handle_message.call_args[0][0]\n+        assert dispatched.source.thread_id == \"222\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}