# NousResearch/hermes-agent · Telegram DM topic session isolation and recovery bugs

## Open now

- 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.

## TL;DR

- 11 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 1 merged PR(s), 7 PR(s) closed without merging.
- A fix was merged to main in PR #31444. Verify it addresses your case.
- **Recommended:** read PR #31444 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-31444`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #30411: \[Bug\]: Telegram DM auto-topic-rename and tool-call routing broken by _recover_telegram_topic_thread_id()
- #30538: fix(gateway): DM topic recovery collapses parallel sessions into one
- #31086: \[Bug\] _recover_telegram_topic_thread_id hijacks every brand-new Telegram DM topic into the previous topic

## Prior & competing PRs

- **#31444: fix(gateway): preserve new Telegram DM topic lanes**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/run.py`, `tests/gateway/test_telegram_thread_fallback.py`, `tests/gateway/test_telegram_topic_mode.py`
  - fix hunks:
  - `gateway/run.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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)
    ```
- **#28605: fix(telegram): preserve new DM topic lanes**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `14ad7b2fc`, `#31444`, `#31086`
- **#29287: fix(gateway): don't hijack brand-new Telegram DM topics into previous session**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#31444`, `#31086`
- **#29546: fix: new Telegram DM topics from All Messages reroute to previous session**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#31444`, `#31086`, `#31086`
- **#30422: fix(gateway): make Telegram root-DM topic pinning opt-in (#30411)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#30411`, `#30422`, `#17172`
- **#31088: fix(gateway): don't hijack brand-new Telegram DM topics into the previous topic**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#31444`, `#31086`, `#31086`
- **#31812: fix(gateway): keep parallel Telegram DM topic sessions from collapsing**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#30538`, `#30411`, `#28605`, `#30422`, `#29794`
- **#40525: fix(gateway): restore Telegram DM topic isolation**. closed without merge
  - maintainer direction: category `volume_quota`; implication `active`

## Fix status

- Merged to main in PR #31444 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-24`.
- Subsystem: `gateway`
