{"repo":"NousResearch/hermes-agent","public_id":"30411","display_label":"Telegram DM topic session isolation and recovery bugs","summary":"Multiple bug reports and fix attempts addressing a function that incorrectly collapses or hijacks new Telegram DM topic sessions into previous ones. The cluster centers on preserving parallel DM topic lane isolation during topic recovery.","subsystem":"gateway","counts":{"members":11,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":31444},"resolution":{"state":"partially_resolved","by":"pr-31444"},"freshness":{"state":"verified","last_verified_at":"2026-05-24T22:28:40+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18327272727272728},"fix_edges":[{"issue_number":31086,"pr_number":31444,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":30411,"title":"[Bug]: Telegram DM auto-topic-rename and tool-call routing broken by _recover_telegram_topic_thread_id()","url":null,"state":"closed"},{"kind":"issue","number":30538,"title":"fix(gateway): DM topic recovery collapses parallel sessions into one","url":null,"state":"closed"},{"kind":"issue","number":31086,"title":"[Bug] _recover_telegram_topic_thread_id hijacks every brand-new Telegram DM topic into the previous topic","url":null,"state":"closed"},{"kind":"pr","number":28605,"title":"fix(telegram): preserve new DM topic lanes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"14ad7b2fc","number":0},{"kind":"pr","value":"#31444","number":31444},{"kind":"pr","value":"#31086","number":31086}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":29287,"title":"fix(gateway): don't hijack brand-new Telegram DM topics into previous session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31444","number":31444},{"kind":"pr","value":"#31086","number":31086}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":29546,"title":"fix: new Telegram DM topics from All Messages reroute to previous session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31444","number":31444},{"kind":"pr","value":"#31086","number":31086},{"kind":"pr","value":"#31086","number":31086}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":30422,"title":"fix(gateway): make Telegram root-DM topic pinning opt-in (#30411)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30411","number":30411},{"kind":"pr","value":"#30422","number":30422},{"kind":"pr","value":"#17172","number":17172}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":31088,"title":"fix(gateway): don't hijack brand-new Telegram DM topics into the previous topic","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31444","number":31444},{"kind":"pr","value":"#31086","number":31086},{"kind":"pr","value":"#31086","number":31086}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":31444,"title":"fix(gateway): preserve new Telegram DM topic lanes","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_telegram_thread_fallback.py","tests/gateway/test_telegram_topic_mode.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -2272,13 +2272,14 @@ def _recover_telegram_topic_thread_id(\n     ) -> Optional[str]:\n         \"\"\"Pin DM-topic routing to the user's last-active topic.\n \n-        Telegram fragments topic-mode DMs two ways: a Reply on a message\n-        in another topic delivers ``message_thread_id`` for *that* topic,\n-        and ``_build_message_event`` strips the thread_id on plain replies\n-        (#3206 — needed for non-topic users). Both route the user to the\n-        wrong session. When topic mode is on, rewrite the thread_id to the\n-        user's most-recent binding if the inbound id is missing/General or\n-        not a known topic for this chat. Returns None to leave it alone.\n+        Telegram can omit ``message_thread_id`` or surface General (``1``)\n+        for some topic-mode DM replies. In those lobby-shaped cases, keep the\n+        conversation attached to the user's most-recent bound topic.\n+\n+        Do not rewrite a non-lobby, previously-unbound thread id: a newly\n+        created Telegram DM topic is also \"unknown\" until the first inbound\n+        message is recorded, and rewriting it would send that brand-new topic's\n+        answer into an older lane. Returns None to leave the source alone.\n         \"\"\"\n         if (\n             source.platform != Platform.TELEGRAM","@@ -2288,6 +2289,14 @@ def _recover_telegram_topic_thread_id(\n             or not self._telegram_topic_mode_enabled(source)\n         ):\n             return None\n+        inbound = str(source.thread_id or \"\")\n+        is_lobby = not inbound or inbound in self._TELEGRAM_GENERAL_TOPIC_IDS\n+        if not is_lobby:\n+            # A non-lobby, unknown thread_id is most likely the first message in\n+            # a brand-new Telegram DM topic. Preserve it so it can be recorded\n+            # as a new independent lane below instead of hijacking the latest\n+            # existing topic binding.\n+            return None\n         session_db = getattr(self, \"_session_db\", None)\n         if session_db is None:\n             return None","@@ -2300,11 +2309,6 @@ def _recover_telegram_topic_thread_id(\n             return None\n         if not bindings:\n             return None\n-        inbound = str(source.thread_id or \"\")\n-        is_lobby = not inbound or inbound in self._TELEGRAM_GENERAL_TOPIC_IDS\n-        known = {str(b.get(\"thread_id\") or \"\") for b in bindings}\n-        if not is_lobby and inbound in known:\n-            return None\n         user_id = str(source.user_id)\n         for b in bindings:  # newest-first\n             if str(b.get(\"user_id\") or \"\") == user_id:"]},{"file":"tests/gateway/test_telegram_thread_fallback.py","hunks":["@@ -98,6 +98,7 @@ def __init__(self, media, caption=None, **kwargs):\n _fake_telegram_ext.CommandHandler = object\n _fake_telegram_ext.CallbackQueryHandler = object\n _fake_telegram_ext.MessageHandler = object\n+_fake_telegram_ext.TypeHandler = object\n _fake_telegram_ext.ContextTypes = SimpleNamespace(DEFAULT_TYPE=object)\n _fake_telegram_ext.filters = object\n _fake_telegram_request = types.ModuleType(\"telegram.request\")"]},{"file":"tests/gateway/test_telegram_topic_mode.py","hunks":["@@ -1175,13 +1175,15 @@ def test_recover_returns_none_for_known_topic(tmp_path):\n     assert runner._recover_telegram_topic_thread_id(_make_source(thread_id=\"222\")) is None\n \n \n-def test_recover_rewrites_unknown_thread_id_to_most_recent(tmp_path):\n-    # Cross-topic Reply leak: inbound thread_id is a Telegram-only id we never bound.\n+def test_recover_preserves_unknown_thread_id_for_new_topic(tmp_path):\n+    # A newly-created Telegram DM topic arrives with a real, previously-unbound\n+    # message_thread_id. It must become its own session lane rather than being\n+    # rewritten to whichever older topic was most recently active.\n     db = SessionDB(db_path=tmp_path / \"state.db\")\n     _seed_two_topic_bindings(db)\n     runner = _make_runner(session_db=db)\n \n-    assert runner._recover_telegram_topic_thread_id(_make_source(thread_id=\"9999\")) == \"222\"\n+    assert runner._recover_telegram_topic_thread_id(_make_source(thread_id=\"9999\")) is None\n \n \n def test_recover_rewrites_lobby_thread_id_to_most_recent(tmp_path):","@@ -1209,6 +1211,31 @@ def test_recover_returns_none_when_no_bindings_yet(tmp_path):\n     assert runner._recover_telegram_topic_thread_id(_make_source(thread_id=None)) is None\n \n \n+def test_recover_returns_none_for_brand_new_topic(tmp_path):\n+    # Regression for #31086: bindings exist for a prior topic but the user\n+    # opened a fresh one (thread_id \"99999\"). Recovery must return None so the\n+    # new topic gets its own session rather than being silently merged into\n+    # the previous topic's session. The hijack was self-reinforcing — because\n+    # the rewrite ran before _record_telegram_topic_binding, the new topic's\n+    # binding row never got written, so every subsequent message in that topic\n+    # looked \"unknown\" and was hijacked again.\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-old\", source=\"telegram\", user_id=\"208214988\")\n+    src_old = _make_source(thread_id=\"12345\")\n+    db.bind_telegram_topic(\n+        chat_id=src_old.chat_id,\n+        thread_id=src_old.thread_id,\n+        user_id=src_old.user_id,\n+        session_key=build_session_key(src_old),\n+        session_id=\"sess-old\",\n+    )\n+    runner = _make_runner(session_db=db)\n+\n+    # \"99999\" is non-lobby and not in the binding table — brand-new topic.\n+    assert runner._recover_telegram_topic_thread_id(_make_source(thread_id=\"99999\")) is None\n+\n+\n def test_list_telegram_topic_bindings_for_chat(tmp_path):\n     db = SessionDB(db_path=tmp_path / \"state.db\")\n     _seed_two_topic_bindings(db)\n"]}]},{"kind":"pr","number":31812,"title":"fix(gateway): keep parallel Telegram DM topic sessions from collapsing","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30538","number":30538},{"kind":"pr","value":"#30411","number":30411},{"kind":"pr","value":"#28605","number":28605},{"kind":"pr","value":"#30422","number":30422},{"kind":"pr","value":"#29794","number":29794}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":40525,"title":"fix(gateway): restore Telegram DM topic isolation","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}