Telegram DM topic session isolation and recovery bugs
NousResearch/hermes-agent · cluster 30411 · gateway
Data as of
Nothing open on GitHub. Fix merged in #31444. Check it covers your case.
The short answer
- Known?
- Yes. 11 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #31444. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #31444. That is separate from the fix event. - Still open
- Nothing open on GitHub.
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.
Open issues0
Open PRs0
Fixes on record
-
#31444 fix(gateway): preserve new Telegram DM topic lanes
files:
gateway/run.py,tests/gateway/test_telegram_thread_fallback.py,tests/gateway/test_telegram_topic_mode.pyShow the diff
-
gateway/run.py@@ -2272,13 +2272,14 @@ def _recover_telegram_topic_thread_id( ) -> Optional[str]: """Pin DM-topic routing to the user's last-active topic. - Telegram fragments topic-mode DMs two ways: a Reply on a message - in another topic delivers ``message_thread_id`` for *that* topic, - and ``_build_message_event`` strips the thread_id on plain replies - (#3206 — needed for non-topic users). Both route the user to the - wrong session. When topic mode is on, rewrite the thread_id to the - user's most-recent binding if the inbound id is missing/General or - not a known topic for this chat. Returns None to leave it alone. + Telegram can omit ``message_thread_id`` or surface General (``1``) + for some topic-mode DM replies. In those lobby-shaped cases, keep the + conversation attached to the user's most-recent bound topic. + + Do not rewrite a non-lobby, previously-unbound thread id: a newly + created Telegram DM topic is also "unknown" until the first inbound + message is recorded, and rewriting it would send that brand-new topic's + answer into an older lane. Returns None to leave the source alone. """ if ( source.platform != Platform.TELEGRAM @@ -2288,6 +2289,14 @@ def _recover_telegram_topic_thread_id( or not self._telegram_topic_mode_enabled(source) ): return None + inbound = str(source.thread_id or "") + is_lobby = not inbound or inbound in self._TELEGRAM_GENERAL_TOPIC_IDS + if not is_lobby: + # A non-lobby, unknown thread_id is most likely the first message in + # a brand-new Telegram DM topic. Preserve it so it can be recorded + # as a new independent lane below instead of hijacking the latest + # existing topic binding. + return None session_db = getattr(self, "_session_db", None) if session_db is None: return None @@ -2300,11 +2309,6 @@ def _recover_telegram_topic_thread_id( return None if not bindings: return None - inbound = str(source.thread_id or "") - is_lobby = not inbound or inbound in self._TELEGRAM_GENERAL_TOPIC_IDS - known = {str(b.get("thread_id") or "") for b in bindings} - if not is_lobby and inbound in known: - return None user_id = str(source.user_id) for b in bindings: # newest-first if str(b.get("user_id") or "") == user_id: -
tests/gateway/test_telegram_thread_fallback.py@@ -98,6 +98,7 @@ def __init__(self, media, caption=None, **kwargs): _fake_telegram_ext.CommandHandler = object _fake_telegram_ext.CallbackQueryHandler = object _fake_telegram_ext.MessageHandler = object +_fake_telegram_ext.TypeHandler = object _fake_telegram_ext.ContextTypes = SimpleNamespace(DEFAULT_TYPE=object) _fake_telegram_ext.filters = object _fake_telegram_request = types.ModuleType("telegram.request") -
tests/gateway/test_telegram_topic_mode.py@@ -1175,13 +1175,15 @@ def test_recover_returns_none_for_known_topic(tmp_path): assert runner._recover_telegram_topic_thread_id(_make_source(thread_id="222")) is None -def test_recover_rewrites_unknown_thread_id_to_most_recent(tmp_path): - # Cross-topic Reply leak: inbound thread_id is a Telegram-only id we never bound. +def test_recover_preserves_unknown_thread_id_for_new_topic(tmp_path): + # A newly-created Telegram DM topic arrives with a real, previously-unbound + # message_thread_id. It must become its own session lane rather than being + # rewritten to whichever older topic was most recently active. db = SessionDB(db_path=tmp_path / "state.db") _seed_two_topic_bindings(db) runner = _make_runner(session_db=db) - assert runner._recover_telegram_topic_thread_id(_make_source(thread_id="9999")) == "222" + assert runner._recover_telegram_topic_thread_id(_make_source(thread_id="9999")) is None 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): assert runner._recover_telegram_topic_thread_id(_make_source(thread_id=None)) is None +def test_recover_returns_none_for_brand_new_topic(tmp_path): + # Regression for #31086: bindings exist for a prior topic but the user + # opened a fresh one (thread_id "99999"). Recovery must return None so the + # new topic gets its own session rather than being silently merged into + # the previous topic's session. The hijack was self-reinforcing — because + # the rewrite ran before _record_telegram_topic_binding, the new topic's + # binding row never got written, so every subsequent message in that topic + # looked "unknown" and was hijacked again. + db = SessionDB(db_path=tmp_path / "state.db") + db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + db.create_session(session_id="sess-old", source="telegram", user_id="208214988") + src_old = _make_source(thread_id="12345") + db.bind_telegram_topic( + chat_id=src_old.chat_id, + thread_id=src_old.thread_id, + user_id=src_old.user_id, + session_key=build_session_key(src_old), + session_id="sess-old", + ) + runner = _make_runner(session_db=db) + + # "99999" is non-lobby and not in the binding table — brand-new topic. + assert runner._recover_telegram_topic_thread_id(_make_source(thread_id="99999")) is None + + def test_list_telegram_topic_bindings_for_chat(tmp_path): db = SessionDB(db_path=tmp_path / "state.db") _seed_two_topic_bindings(db)
-
7 PRs closed without merging
From the close comments: superseded by another PR 3 · duplicate 2 · PR volume cleanup 1 · merged via another change 1
-
#28605 fix(telegram): preserve new DM topic lanes · merged via another change →
14ad7b2,#31444,#31086 -
#29287 fix(gateway): don't hijack brand-new Telegram DM topics into previous session · superseded by another PR →
#31444,#31086 -
#29546 fix: new Telegram DM topics from All Messages reroute to previous session · superseded by another PR →
#31444,#31086 -
#30422 fix(gateway): make Telegram root-DM topic pinning opt-in (#30411) · duplicate →
#30411,#30422,#17172 -
#31088 fix(gateway): don't hijack brand-new Telegram DM topics into the previous topic · superseded by another PR →
#31444,#31086
Closed issues · 3
- Closed#30411[Bug]: Telegram DM auto-topic-rename and tool-call routing broken by _recover_telegram_topic_thread_id()
- Closed#30538fix(gateway): DM topic recovery collapses parallel sessions into one
- Closed#31086[Bug] _recover_telegram_topic_thread_id hijacks every brand-new Telegram DM topic into the previous topic
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.18: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #31444
- Closed issues
- 3
- Attempts
- 7 PRs closed without merging
- Fix link checked
- 24 May 2026