# NousResearch/hermes-agent · Telegram DM topic thread_id lost after session split

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #28524. Verify it addresses your case.
- **Recommended:** read PR #28524 (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: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: 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

- #27166: \[Bug\] Telegram DM topic response routed to All Messages after session split

## Prior & competing PRs

- **#28524: fix(gateway): restore Telegram DM topic thread_id after session split (closes #27166)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/run.py`, `hermes_state.py`, `scripts/release.py`, `tests/gateway/test_telegram_topic_mode.py`
  - fix hunks:
  - `gateway/run.py`

    ```diff
    @@ -16544,6 +16544,37 @@ def _approval_notify_sync(approval_data: dict) -> None:
                         entry.session_id = agent.session_id
                         self.session_store._save()
     
    +                # If this is a Telegram DM and source.thread_id was lost during
    +                # the session split (synthetic / recovered event), restore it
    +                # from the binding so _thread_metadata_for_source produces the
    +                # correct message_thread_id instead of routing to the General
    +                # thread.  Failure here is non-fatal — we log and continue;
    +                # worst case the message lands in General, which is the
    +                # pre-fix behaviour.
    +                if (
    +                    getattr(source, "platform", None) == Platform.TELEGRAM
    +                    and getattr(source, "chat_type", None) == "dm"
    +                    and getattr(source, "thread_id", None) is None
    +                    and self._session_db is not None
    +                ):
    +                    try:
    +                        _binding = self._session_db.get_telegram_topic_binding_by_session(
    +                            session_id=agent.session_id,
    +                        )
    +                        if _binding and _binding.get("thread_id"):
    +                            source.thread_id = str(_binding["thread_id"])
    +                            logger.debug(
    +                                "Restored source.thread_id=%s from binding after session split %s → %s",
    +                                source.thread_id,
    +                                session_id,
    +                                agent.session_id,
    +                            )
    +                    except Exception:
    +                        logger.debug(
    +                            "Failed to restore thread_id from binding after session split",
    +                            exc_info=True,
    +                        )
    +
                 effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
     
                 # When compression created a new session, the messages list was
    ```
  - `hermes_state.py`

    ```diff
    @@ -2852,6 +2852,30 @@ def list_telegram_topic_bindings_for_chat(
                     return []
             return [dict(row) for row in rows]
     
    +    def get_telegram_topic_binding_by_session(
    +        self,
    +        *,
    +        session_id: str,
    +    ) -> Optional[Dict[str, Any]]:
    +        """Return the Telegram DM topic binding for a given session_id, if present.
    +
    +        Uses the UNIQUE INDEX on telegram_dm_topic_bindings(session_id) for an
    +        efficient reverse lookup. Returns None when the session has no binding or
    +        the table does not exist yet.
    +        """
    +        with self._lock:
    +            try:
    +                row = self._conn.execute(
    +                    """
    +                    SELECT * FROM telegram_dm_topic_bindings
    +                    WHERE session_id = ?
    +                    """,
    +                    (str(session_id),),
    +                ).fetchone()
    +            except sqlite3.OperationalError:
    +                return None
    +        return dict(row) if row else None
    +
         def bind_telegram_topic(
             self,
             *,
    ```
  - `scripts/release.py`

    ```diff
    @@ -182,6 +182,7 @@
         "[redacted:email]": "kiranvk-2011",  # PR #24815 salvage (image documents → vision)
         "[redacted:email]": "rak135",  # PR #25960 salvage (Windows /restart)
         "[redacted:email]": "B0Tch1",  # PR #27634 salvage (disable_topic_auto_rename)
    +    "[redacted:email]": "jackjin1997",  # PR #27239 salvage (restore DM topic thread_id after split)
         "282919977+eliteworkstation94-ai@users.noreply.github.com": "eliteworkstation94-ai",  # PR #28157 salvage (group reply session splits)
         "[redacted:email]": "hllqkb",
         "25840394+Bongulielmi@users.noreply.github.com": "Bongulielmi",
    ```
  - `tests/gateway/test_telegram_topic_mode.py`

    ```diff
    @@ -1229,6 +1229,100 @@ def test_list_telegram_topic_bindings_for_chat_no_table(tmp_path):
         assert tables == set()
     
     
    +# ---------------------------------------------------------------------------
    +# Tests for get_telegram_topic_binding_by_session (issue #27166)
    +# ---------------------------------------------------------------------------
     
    +def test_get_telegram_topic_binding_by_session_returns_binding(tmp_path):
    +    """Reverse lookup by session_id returns the binding row."""
    +    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-27166", source="telegram", user_id="208214988")
    +    db.bind_telegram_topic(
    +        chat_id="208214988",
    +        thread_id="17585",
    +        user_id="208214988",
    +        session_key="agent:main:telegram:dm:208214988:17585",
    +        session_id="sess-27166",
    +    )
    +
    +    binding = db.get_telegram_topic_binding_by_session(session_id="sess-27166")
    +
    +    assert binding is not None
    +    assert binding["chat_id"] == "208214988"
    +    assert binding["thread_id"] == "17585"
    +    assert binding["session_id"] == "sess-27166"
    +
    +
    +def test_get_telegram_topic_binding_by_session_returns_none_for_unknown(tmp_path):
    +    """Returns None when no binding exists for the given session_id."""
    +    db = SessionDB(db_path=tmp_path / "state.db")
    +    db.apply_telegram_topic_migration()
    +
    +    result = db.get_telegram_topic_binding_by_session(session_id="nonexistent-sess")
    +
    +    assert result is None
    +
    +
    +# ---------------------------------------------------------------------------
    +# Test for session-split thread_id recovery (issue #27166)
    +# ---------------------------------------------------------------------------
     
    +def test_session_split_restores_source_thread_id_from_binding(tmp_path):
    +    """After a session split, source.thread_id is restored from the binding.
    +
    +    Simulates the case where context compression creates a new session_id and
    +    source.thread_id is None (synthetic/recovered event). The recovery block
    +    must look up the binding by the new session_id and restore thread_id on
    +    source so that _thread_metadata_for_source returns the correct thread.
    +    """
    +    from gateway.run import GatewayRunner
    +    from gateway.config import Platform
    +
    +    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-split-new", source="telegram", user_id="208214988")
    +    db.bind_telegram_topic(
    +        chat_id="208214988",
    +        thread_id="17585",
    +        user_id="208214988",
    +        session_key="agent:main:telegram:dm:208214988:17585",
    +        session_id="sess-split-new",
    +    )
    +
    +    runner = object.__new__(GatewayRunner)
    +    runner._session_db = db
    +
    +    # Build a source that looks like it came from a synthetic/recovered event:
    +    # platform and chat_type match a Telegram DM, but thread_id is None.
    +    source = _make_source(thread_id=None)
    +    assert source.platform == Platform.TELEGRAM
    +    assert source.chat_type == "dm"
    +    assert source.thread_id is None
    +
    +    # Simulate the session-split recovery block logic directly.
    +    if (
    +        getattr(source, "platform", None) == Platform.TELEGRAM
    +        and getattr(source, "chat_type", None) == "dm"
    +        and getattr(source, "thread_id", None) is None
    +        and runner._session_db is not None
    +    ):
    +        try:
    +            _binding = runner._session_db.get_telegram_topic_binding_by_session(
    +                session_id="sess-split-new",
    +            )
    +            if _binding and _binding.get("thread_id"):
    +                source.thread_id = str(_binding["thread_id"])
    +        except Exception:
    +            pass
    +
    +    assert source.thread_id == "17585", (
    +        "thread_id must be restored from the binding after session split"
    +    )
     
    +    # Confirm _thread_metadata_for_source now returns non-None.
    +    runner.config = _make_runner(session_db=db).config
    +    runner.adapters = _make_runner(session_db=db).adapters
    +    meta = GatewayRunner._thread_metadata_for_source(runner, source)
    +    assert meta is not None
    +    assert meta["thread_id"] == "17585"
    ```
- **#27239: fix(gateway): restore Telegram DM topic thread_id after session split**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#28524`

## Fix status

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