{"repo":"NousResearch/hermes-agent","public_id":"30479","display_label":"Telegram forum topic session override isolation fixes","summary":"These issues and patches address a bug where per-session /model and /reasoning overrides are silently dropped in Telegram Group Forum threads and during session splits or context compression. The work focuses on isolating and preserving session state across forum topics and session recovery scenarios.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":41715,"merged_at":"2026-06-08T05:10:32+00:00"},"resolution":{"state":"resolved","by":"pr-41715"},"freshness":{"state":"verified","last_verified_at":"2026-06-08T05:10:32+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":30479,"pr_number":41715,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-08T05:10:32+00:00"}],"members":[{"kind":"issue","number":30479,"title":"Bug: Session-only /model overrides are silently lost on Telegram Group Forum threads and during Session Splits (Context Compression)","url":null,"state":"closed"},{"kind":"pr","number":30513,"title":"fix(gateway): preserve session model override across forum-topic callbacks and session splits","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"2a10da3a16f9d437813b2d3673646ad2ea1e8116","number":0},{"kind":"pr","value":"#30479","number":30479}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":41715,"title":"fix(gateway): keep /model + /reasoning overrides on topic recovery & compression splits","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_session_override_thread_recovery.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -2618,6 +2618,34 @@ def _recover_telegram_topic_thread_id(\n                 return None\n         return None\n \n+    def _normalize_source_for_session_key(\n+        self,\n+        source: SessionSource,\n+    ) -> SessionSource:\n+        \"\"\"Apply Telegram DM topic recovery to a source for session-key purposes.\n+\n+        ``_handle_message_with_agent`` rewrites ``source.thread_id`` via\n+        ``_recover_telegram_topic_thread_id`` *before* deriving the session\n+        key for a normal message turn (a lobby/stripped reply gets pinned to\n+        the user's last-active topic).  Session-scoped command handlers like\n+        ``/model`` and ``/reasoning`` derive their override key from the raw\n+        inbound ``event.source``, which skips that recovery — so the override\n+        is stored under a different key than the next message turn reads,\n+        and the override is silently dropped on Telegram forum topics and\n+        after compression session splits (#30479).\n+\n+        Returns a recovery-normalized copy when a rewrite applies, otherwise\n+        the original source unchanged.  Always derive the override storage key\n+        from the result so storage and read use an identical key.\n+        \"\"\"\n+        try:\n+            recovered = self._recover_telegram_topic_thread_id(source)\n+        except Exception:\n+            return source\n+        if recovered is None:\n+            return source\n+        return dataclasses.replace(source, thread_id=recovered)\n+\n     def _resolve_session_agent_runtime(\n         self,\n         *,","@@ -11143,6 +11171,11 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:\n \n         # Check for session override\n         source = event.source\n+        # Normalize the source the same way a normal message turn does\n+        # (Telegram DM topic recovery) before deriving the override key, so\n+        # the override is stored under the key the next message turn reads\n+        # (#30479).\n+        source = self._normalize_source_for_session_key(source)\n         session_key = self._session_key_for_source(source)\n         override = self._session_model_overrides.get(session_key, {})\n         if override:","@@ -12890,7 +12923,11 @@ async def _handle_reasoning_command(self, event: MessageEvent) -> str:\n         raw_args = event.get_command_args().strip()\n         args, persist_global = self._parse_reasoning_command_args(raw_args)\n         config_path = _hermes_home / \"config.yaml\"\n-        session_key = self._session_key_for_source(event.source)\n+        # Normalize the source (Telegram DM topic recovery) before deriving\n+        # the override key so storage matches the key the next message turn\n+        # reads — same fix as /model (#30479).\n+        _reasoning_source = self._normalize_source_for_session_key(event.source)\n+        session_key = self._session_key_for_source(_reasoning_source)\n         self._show_reasoning = self._load_show_reasoning()\n         self._reasoning_config = self._resolve_session_reasoning_config(\n             source=event.source,"]},{"file":"tests/gateway/test_session_override_thread_recovery.py","hunks":["@@ -0,0 +1,110 @@\n+\"\"\"Regression tests for #30479 — session-scoped /model and /reasoning overrides\n+silently lost on Telegram forum/DM topics and after compression session splits.\n+\n+Root cause: ``_handle_message_with_agent`` rewrites ``source.thread_id`` via\n+``_recover_telegram_topic_thread_id`` (lobby/stripped reply -> the user's\n+last-active bound topic) *before* deriving the session key for a message turn.\n+The ``/model`` and ``/reasoning`` command handlers derived their override key\n+from the raw inbound ``event.source``, skipping that recovery — so the override\n+was stored under one key and the next message turn read a different key, and the\n+override was dropped.\n+\n+Fix: both command handlers normalize the source via\n+``_normalize_source_for_session_key`` before deriving the override key, so\n+storage and read keys are identical.\n+\"\"\"\n+\n+import threading\n+from unittest.mock import MagicMock\n+\n+import gateway.run as gateway_run\n+from gateway.config import Platform\n+from gateway.session import SessionSource, build_session_key\n+\n+\n+def _make_runner(recovered_thread_id=None):\n+    runner = object.__new__(gateway_run.GatewayRunner)\n+    runner.config = None\n+    runner.session_store = None\n+    runner._session_db = None\n+    runner._session_model_overrides = {}\n+    runner._session_reasoning_overrides = {}\n+    runner._agent_cache = {}\n+    runner._agent_cache_lock = threading.Lock()\n+    # Stub topic recovery: returns the bound topic id for a lobby message,\n+    # None otherwise (the real method's contract).\n+    runner._recover_telegram_topic_thread_id = MagicMock(return_value=recovered_thread_id)\n+    return runner\n+\n+\n+def _topic_dm_source(thread_id):\n+    \"\"\"A Telegram DM in topic mode. thread_id=\"\" / \"1\" == General/lobby.\"\"\"\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"555\",\n+        chat_name=\"Forum DM\",\n+        chat_type=\"dm\",\n+        user_id=\"user-1\",\n+        thread_id=thread_id,\n+    )\n+\n+\n+def test_normalize_rewrites_lobby_thread_to_bound_topic():\n+    \"\"\"A lobby (stripped) reply gets pinned to the user's bound topic id.\"\"\"\n+    runner = _make_runner(recovered_thread_id=\"42\")\n+    src = _topic_dm_source(thread_id=\"\")  # lobby/General — no message_thread_id\n+\n+    normalized = runner._normalize_source_for_session_key(src)\n+\n+    assert normalized.thread_id == \"42\"\n+    # Original source is left untouched (we return a copy).\n+    assert src.thread_id == \"\"\n+\n+\n+def test_normalize_passthrough_when_no_recovery():\n+    \"\"\"No recovery -> source returned unchanged (identity).\"\"\"\n+    runner = _make_runner(recovered_thread_id=None)\n+    src = _topic_dm_source(thread_id=\"42\")\n+\n+    normalized = runner._normalize_source_for_session_key(src)\n+\n+    assert normalized is src\n+\n+\n+def test_normalize_swallows_recovery_exceptions():\n+    \"\"\"Recovery raising must not break the command — return the raw source.\"\"\"\n+    runner = _make_runner()\n+    runner._recover_telegram_topic_thread_id = MagicMock(side_effect=RuntimeError(\"boom\"))\n+    src = _topic_dm_source(thread_id=\"\")\n+\n+    normalized = runner._normalize_source_for_session_key(src)\n+\n+    assert normalized is src\n+\n+\n+def test_override_key_matches_message_turn_key_after_recovery():\n+    \"\"\"The bug, end to end at the key level.\n+\n+    /model arrives as a lobby reply (thread_id=\"\").  The next message turn\n+    runs recovery and lands on the bound topic (\"42\").  After the fix, the\n+    key the command stores under must equal the key the message turn reads.\n+    \"\"\"\n+    runner = _make_runner(recovered_thread_id=\"42\")\n+\n+    # --- /model command path (raw inbound is a lobby reply) ---\n+    command_source = _topic_dm_source(thread_id=\"\")\n+    normalized_command_source = runner._normalize_source_for_session_key(command_source)\n+    # _session_key_for_source falls back to build_session_key when there is no\n+    # session_store; emulate that resolution here directly.\n+    command_key = build_session_key(normalized_command_source)\n+\n+    # --- next message turn path (recovery already applied to source) ---\n+    message_turn_source = _topic_dm_source(thread_id=\"42\")\n+    message_turn_key = build_session_key(message_turn_source)\n+\n+    assert command_key == message_turn_key\n+\n+    # And the orphaning the bug caused: storing under the RAW (pre-recovery)\n+    # key would NOT be found by the message turn.\n+    raw_key = build_session_key(command_source)\n+    assert raw_key != message_turn_key\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}