{"repo":"NousResearch/hermes-agent","public_id":"13195","display_label":"Telegram thread ID session-splitting bug fixes","summary":"These issues and pull requests address a bug where Telegram group messages carrying message_thread_id values cause unexpected session splits or misrouted human-handoff replies. Fixes center on normalizing thread IDs through a shared helper to prevent incorrect session routing.","subsystem":"gateway","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":28506},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-19T05:25:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":28153,"pr_number":28506,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":13195,"title":"Telegram groups can split into unexpected sessions when inbound messages carry message_thread_id without user-created topics","url":null,"state":"closed"},{"kind":"issue","number":28153,"title":"Telegram group reply-derived thread IDs can route human-handoff “done” replies into the wrong session","url":null,"state":"closed"},{"kind":"pr","number":13338,"title":"fix(telegram): avoid unexpected thread session splits","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7b2bcba1679e2bf0c73612ce9583982d970162d3","number":0},{"kind":"pr","value":"#13195","number":13195}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":28157,"title":"fix: avoid Telegram group reply thread session splits","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28506","number":28506}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28506,"title":"fix: avoid Telegram group reply thread session splits","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","scripts/release.py","tests/gateway/test_dm_topics.py","tests/gateway/test_telegram_thread_fallback.py"],"fix_hunks":[{"file":"gateway/platforms/telegram.py","hunks":["@@ -4795,25 +4795,28 @@ def _build_message_event(\n         elif chat.type == ChatType.CHANNEL:\n             chat_type = \"channel\"\n \n-        # Resolve DM topic name and skill binding.\n-        # In private chats, only preserve thread ids for real topic messages\n-        # (is_topic_message=True).  Telegram puts message_thread_id on every\n-        # DM that is a reply, even when the user is just replying to a\n-        # previous message in the same DM — that bogus id then routes to a\n-        # nonexistent thread and Telegram returns 'Message thread not found'\n-        # on send (#3206).\n+        # Resolve Telegram topic name and skill binding.\n+        # Only preserve message_thread_id when Telegram marks the message as\n+        # a real topic/forum message. Telegram can also populate\n+        # message_thread_id for ordinary reply UI anchors; treating those as\n+        # durable session threads fragments workflows such as CAPTCHA/login\n+        # handoffs where the user later replies \"done\" in the same group.\n+        # Private chats have the same pitfall: only real DM topic messages\n+        # (is_topic_message=True) should keep the thread id, otherwise sends\n+        # can hit Telegram's 'Message thread not found' error (#3206).\n         thread_id_raw = message.message_thread_id\n         is_topic_message = bool(getattr(message, \"is_topic_message\", False))\n+        is_forum_group = getattr(chat, \"is_forum\", False) is True\n         thread_id_str = None\n         if thread_id_raw is not None:\n-            if chat_type == \"group\":\n+            if chat_type == \"group\" and (is_topic_message or is_forum_group):\n                 thread_id_str = str(thread_id_raw)\n             elif chat_type == \"dm\" and is_topic_message:\n                 thread_id_str = str(thread_id_raw)\n         # For forum groups without an explicit topic, default to the\n         # General-topic id so the gateway routes back to the General topic\n         # rather than dropping into the bot's main channel (#22423).\n-        if chat_type == \"group\" and thread_id_str is None and getattr(chat, \"is_forum\", False):\n+        if chat_type == \"group\" and thread_id_str is None and is_forum_group:\n             thread_id_str = self._GENERAL_TOPIC_THREAD_ID\n         chat_topic = None\n         topic_skill = None"]},{"file":"scripts/release.py","hunks":["@@ -171,6 +171,7 @@\n     \"[redacted:email]\": \"fabiosiqueira\",  # PR #27212 salvage (bg-process notif anchor)\n     \"[redacted:email]\": \"falconexe\",  # PR #24511 salvage (sticky-IP reset)\n     \"[redacted:email]\": \"fonhal\",  # PR #27865/#27861 salvage (mention entities / typing fallback)\n+    \"[redacted:email]\": \"eliteworkstation94-ai\",  # PR #28157 salvage (group reply session splits)\n     \"[redacted:email]\": \"hllqkb\",\n     \"[redacted:email]\": \"Bongulielmi\",\n     \"[redacted:email]\": \"JTroyerOvermatch\","]},{"file":"tests/gateway/test_dm_topics.py","hunks":["@@ -449,13 +449,15 @@ def test_cache_dm_topic_from_message_no_overwrite():\n \n def _make_mock_message(chat_id=111, chat_type=\"private\", text=\"hello\", thread_id=None,\n                        user_id=42, user_name=\"Test User\", forum_topic_created=None,\n-                       is_topic_message=None):\n+                       is_topic_message=None, is_forum=None):\n     \"\"\"Create a mock Telegram Message for _build_message_event tests.\"\"\"\n     chat = SimpleNamespace(\n         id=chat_id,\n         type=chat_type,\n         title=None,\n     )\n+    if is_forum is not None:\n+        chat.is_forum = is_forum\n     # Add full_name attribute for DM chats\n     if not hasattr(chat, \"full_name\"):\n         chat.full_name = user_name","@@ -594,7 +596,12 @@ def test_group_topic_skill_binding():\n     ])\n \n     msg = _make_mock_message(\n-        chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=5, text=\"hello\"\n+        chat_id=-1001234567890,\n+        chat_type=_ChatType.SUPERGROUP,\n+        thread_id=5,\n+        text=\"hello\",\n+        is_topic_message=True,\n+        is_forum=True,\n     )\n     event = adapter._build_message_event(msg, MessageType.TEXT)\n ","@@ -617,7 +624,12 @@ def test_group_topic_skill_binding_second_topic():\n     ])\n \n     msg = _make_mock_message(\n-        chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=12, text=\"deal update\"\n+        chat_id=-1001234567890,\n+        chat_type=_ChatType.SUPERGROUP,\n+        thread_id=12,\n+        text=\"deal update\",\n+        is_topic_message=True,\n+        is_forum=True,\n     )\n     event = adapter._build_message_event(msg, MessageType.TEXT)\n ","@@ -639,7 +651,12 @@ def test_group_topic_no_skill_binding():\n     ])\n \n     msg = _make_mock_message(\n-        chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=1, text=\"hey\"\n+        chat_id=-1001234567890,\n+        chat_type=_ChatType.SUPERGROUP,\n+        thread_id=1,\n+        text=\"hey\",\n+        is_topic_message=True,\n+        is_forum=True,\n     )\n     event = adapter._build_message_event(msg, MessageType.TEXT)\n ","@@ -661,7 +678,12 @@ def test_group_topic_unmapped_thread_id():\n     ])\n \n     msg = _make_mock_message(\n-        chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=999, text=\"random\"\n+        chat_id=-1001234567890,\n+        chat_type=_ChatType.SUPERGROUP,\n+        thread_id=999,\n+        text=\"random\",\n+        is_topic_message=True,\n+        is_forum=True,\n     )\n     event = adapter._build_message_event(msg, MessageType.TEXT)\n ","@@ -683,7 +705,12 @@ def test_group_topic_unmapped_chat_id():\n     ])\n \n     msg = _make_mock_message(\n-        chat_id=-1009999999999, chat_type=_ChatType.SUPERGROUP, thread_id=5, text=\"wrong group\"\n+        chat_id=-1009999999999,\n+        chat_type=_ChatType.SUPERGROUP,\n+        thread_id=5,\n+        text=\"wrong group\",\n+        is_topic_message=True,\n+        is_forum=True,\n     )\n     event = adapter._build_message_event(msg, MessageType.TEXT)\n ","@@ -720,7 +747,12 @@ def test_group_topic_chat_id_int_string_coercion():\n     ])\n \n     msg = _make_mock_message(\n-        chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=7, text=\"test\"\n+        chat_id=-1001234567890,\n+        chat_type=_ChatType.SUPERGROUP,\n+        thread_id=7,\n+        text=\"test\",\n+        is_topic_message=True,\n+        is_forum=True,\n     )\n     event = adapter._build_message_event(msg, MessageType.TEXT)\n "]},{"file":"tests/gateway/test_telegram_thread_fallback.py","hunks":["@@ -134,6 +134,70 @@ def _make_adapter():\n     return adapter\n \n \n+def test_non_forum_group_reply_thread_id_does_not_fork_session_key():\n+    \"\"\"Reply-derived thread ids in ordinary groups must not create topic lanes.\"\"\"\n+    from gateway.platforms import telegram as telegram_mod\n+\n+    adapter = _make_adapter()\n+    message = SimpleNamespace(\n+        text=\"Done\",\n+        caption=None,\n+        chat=SimpleNamespace(\n+            id=-100123,\n+            type=telegram_mod.ChatType.SUPERGROUP,\n+            is_forum=False,\n+            title=\"Regular group\",\n+        ),\n+        from_user=SimpleNamespace(id=456, full_name=\"Alice\"),\n+        message_thread_id=461,\n+        is_topic_message=False,\n+        reply_to_message=SimpleNamespace(\n+            message_id=460,\n+            text=\"Please complete the CAPTCHA/login, then reply done.\",\n+            caption=None,\n+        ),\n+        message_id=462,\n+        date=None,\n+    )\n+\n+    event = adapter._build_message_event(message, msg_type=MessageType.TEXT)\n+\n+    assert event.source.chat_id == \"-100123\"\n+    assert event.source.chat_type == \"group\"\n+    assert event.source.thread_id is None\n+    assert build_session_key(event.source) == \"agent:main:telegram:group:-100123:456\"\n+\n+\n+def test_forum_group_topic_message_preserves_thread_session_key():\n+    \"\"\"Real Telegram forum-topic messages should still route by topic id.\"\"\"\n+    from gateway.platforms import telegram as telegram_mod\n+\n+    adapter = _make_adapter()\n+    message = SimpleNamespace(\n+        text=\"hello from topic\",\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=17585,\n+        is_topic_message=True,\n+        reply_to_message=None,\n+        message_id=10,\n+        date=None,\n+    )\n+\n+    event = adapter._build_message_event(message, msg_type=MessageType.TEXT)\n+\n+    assert event.source.chat_id == \"-100123\"\n+    assert event.source.chat_type == \"group\"\n+    assert event.source.thread_id == \"17585\"\n+    assert build_session_key(event.source) == \"agent:main:telegram:group:-100123:17585\"\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"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}