{"repo":"NousResearch/hermes-agent","public_id":"27166","display_label":"Telegram DM topic thread_id lost after session split","summary":"A bug where Telegram DM topic responses are routed to the wrong thread after a session split, and related fix attempts targeting thread_id restoration in the gateway.","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":28524,"merged_at":"2026-05-19T05:42:29+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-19T05:42:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":27166,"pr_number":28524,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-19T05:42:29+00:00"}],"members":[{"kind":"issue","number":27166,"title":"[Bug] Telegram DM topic response routed to All Messages after session split","url":null,"state":"closed"},{"kind":"pr","number":27239,"title":"fix(gateway): restore Telegram DM topic thread_id after session split","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28524","number":28524}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28524,"title":"fix(gateway): restore Telegram DM topic thread_id after session split (closes #27166)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_state.py","scripts/release.py","tests/gateway/test_telegram_topic_mode.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -16544,6 +16544,37 @@ def _approval_notify_sync(approval_data: dict) -> None:\n                     entry.session_id = agent.session_id\n                     self.session_store._save()\n \n+                # If this is a Telegram DM and source.thread_id was lost during\n+                # the session split (synthetic / recovered event), restore it\n+                # from the binding so _thread_metadata_for_source produces the\n+                # correct message_thread_id instead of routing to the General\n+                # thread.  Failure here is non-fatal — we log and continue;\n+                # worst case the message lands in General, which is the\n+                # pre-fix behaviour.\n+                if (\n+                    getattr(source, \"platform\", None) == Platform.TELEGRAM\n+                    and getattr(source, \"chat_type\", None) == \"dm\"\n+                    and getattr(source, \"thread_id\", None) is None\n+                    and self._session_db is not None\n+                ):\n+                    try:\n+                        _binding = self._session_db.get_telegram_topic_binding_by_session(\n+                            session_id=agent.session_id,\n+                        )\n+                        if _binding and _binding.get(\"thread_id\"):\n+                            source.thread_id = str(_binding[\"thread_id\"])\n+                            logger.debug(\n+                                \"Restored source.thread_id=%s from binding after session split %s → %s\",\n+                                source.thread_id,\n+                                session_id,\n+                                agent.session_id,\n+                            )\n+                    except Exception:\n+                        logger.debug(\n+                            \"Failed to restore thread_id from binding after session split\",\n+                            exc_info=True,\n+                        )\n+\n             effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id\n \n             # When compression created a new session, the messages list was"]},{"file":"hermes_state.py","hunks":["@@ -2852,6 +2852,30 @@ def list_telegram_topic_bindings_for_chat(\n                 return []\n         return [dict(row) for row in rows]\n \n+    def get_telegram_topic_binding_by_session(\n+        self,\n+        *,\n+        session_id: str,\n+    ) -> Optional[Dict[str, Any]]:\n+        \"\"\"Return the Telegram DM topic binding for a given session_id, if present.\n+\n+        Uses the UNIQUE INDEX on telegram_dm_topic_bindings(session_id) for an\n+        efficient reverse lookup. Returns None when the session has no binding or\n+        the table does not exist yet.\n+        \"\"\"\n+        with self._lock:\n+            try:\n+                row = self._conn.execute(\n+                    \"\"\"\n+                    SELECT * FROM telegram_dm_topic_bindings\n+                    WHERE session_id = ?\n+                    \"\"\",\n+                    (str(session_id),),\n+                ).fetchone()\n+            except sqlite3.OperationalError:\n+                return None\n+        return dict(row) if row else None\n+\n     def bind_telegram_topic(\n         self,\n         *,"]},{"file":"scripts/release.py","hunks":["@@ -182,6 +182,7 @@\n     \"[redacted:email]\": \"kiranvk-2011\",  # PR #24815 salvage (image documents → vision)\n     \"[redacted:email]\": \"rak135\",  # PR #25960 salvage (Windows /restart)\n     \"[redacted:email]\": \"B0Tch1\",  # PR #27634 salvage (disable_topic_auto_rename)\n+    \"[redacted:email]\": \"jackjin1997\",  # PR #27239 salvage (restore DM topic thread_id after split)\n     \"282919977+eliteworkstation94-ai@users.noreply.github.com\": \"eliteworkstation94-ai\",  # PR #28157 salvage (group reply session splits)\n     \"[redacted:email]\": \"hllqkb\",\n     \"25840394+Bongulielmi@users.noreply.github.com\": \"Bongulielmi\","]},{"file":"tests/gateway/test_telegram_topic_mode.py","hunks":["@@ -1229,6 +1229,100 @@ def test_list_telegram_topic_bindings_for_chat_no_table(tmp_path):\n     assert tables == set()\n \n \n+# ---------------------------------------------------------------------------\n+# Tests for get_telegram_topic_binding_by_session (issue #27166)\n+# ---------------------------------------------------------------------------\n \n+def test_get_telegram_topic_binding_by_session_returns_binding(tmp_path):\n+    \"\"\"Reverse lookup by session_id returns the binding row.\"\"\"\n+    db = SessionDB(db_path=tmp_path / \"state.db\")\n+    db.enable_telegram_topic_mode(chat_id=\"208214988\", user_id=\"208214988\")\n+    db.create_session(session_id=\"sess-27166\", source=\"telegram\", user_id=\"208214988\")\n+    db.bind_telegram_topic(\n+        chat_id=\"208214988\",\n+        thread_id=\"17585\",\n+        user_id=\"208214988\",\n+        session_key=\"agent:main:telegram:dm:208214988:17585\",\n+        session_id=\"sess-27166\",\n+    )\n+\n+    binding = db.get_telegram_topic_binding_by_session(session_id=\"sess-27166\")\n+\n+    assert binding is not None\n+    assert binding[\"chat_id\"] == \"208214988\"\n+    assert binding[\"thread_id\"] == \"17585\"\n+    assert binding[\"session_id\"] == \"sess-27166\"\n+\n+\n+def test_get_telegram_topic_binding_by_session_returns_none_for_unknown(tmp_path):\n+    \"\"\"Returns None when no binding exists for the given session_id.\"\"\"\n+    db = SessionDB(db_path=tmp_path / \"state.db\")\n+    db.apply_telegram_topic_migration()\n+\n+    result = db.get_telegram_topic_binding_by_session(session_id=\"nonexistent-sess\")\n+\n+    assert result is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# Test for session-split thread_id recovery (issue #27166)\n+# ---------------------------------------------------------------------------\n \n+def test_session_split_restores_source_thread_id_from_binding(tmp_path):\n+    \"\"\"After a session split, source.thread_id is restored from the binding.\n+\n+    Simulates the case where context compression creates a new session_id and\n+    source.thread_id is None (synthetic/recovered event). The recovery block\n+    must look up the binding by the new session_id and restore thread_id on\n+    source so that _thread_metadata_for_source returns the correct thread.\n+    \"\"\"\n+    from gateway.run import GatewayRunner\n+    from gateway.config import Platform\n+\n+    db = SessionDB(db_path=tmp_path / \"state.db\")\n+    db.enable_telegram_topic_mode(chat_id=\"208214988\", user_id=\"208214988\")\n+    db.create_session(session_id=\"sess-split-new\", source=\"telegram\", user_id=\"208214988\")\n+    db.bind_telegram_topic(\n+        chat_id=\"208214988\",\n+        thread_id=\"17585\",\n+        user_id=\"208214988\",\n+        session_key=\"agent:main:telegram:dm:208214988:17585\",\n+        session_id=\"sess-split-new\",\n+    )\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner._session_db = db\n+\n+    # Build a source that looks like it came from a synthetic/recovered event:\n+    # platform and chat_type match a Telegram DM, but thread_id is None.\n+    source = _make_source(thread_id=None)\n+    assert source.platform == Platform.TELEGRAM\n+    assert source.chat_type == \"dm\"\n+    assert source.thread_id is None\n+\n+    # Simulate the session-split recovery block logic directly.\n+    if (\n+        getattr(source, \"platform\", None) == Platform.TELEGRAM\n+        and getattr(source, \"chat_type\", None) == \"dm\"\n+        and getattr(source, \"thread_id\", None) is None\n+        and runner._session_db is not None\n+    ):\n+        try:\n+            _binding = runner._session_db.get_telegram_topic_binding_by_session(\n+                session_id=\"sess-split-new\",\n+            )\n+            if _binding and _binding.get(\"thread_id\"):\n+                source.thread_id = str(_binding[\"thread_id\"])\n+        except Exception:\n+            pass\n+\n+    assert source.thread_id == \"17585\", (\n+        \"thread_id must be restored from the binding after session split\"\n+    )\n \n+    # Confirm _thread_metadata_for_source now returns non-None.\n+    runner.config = _make_runner(session_db=db).config\n+    runner.adapters = _make_runner(session_db=db).adapters\n+    meta = GatewayRunner._thread_metadata_for_source(runner, source)\n+    assert meta is not None\n+    assert meta[\"thread_id\"] == \"17585\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}