{"repo":"NousResearch/hermes-agent","public_id":"49634","display_label":"Gateway multiplex Telegram responses routed through wrong bot","summary":"The members concern the gateway's multiplex mode, where Telegram DM responses may be routed through the wrong bot or adapter, with one bug report citing a chat_id collision. Two items are fix-oriented changes (one described as a salvage of #56854) aimed at keeping responses on the owning profile's bot; their outcomes are not stated in the titles.","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":59310,"merged_at":"2026-07-06T04:49:00+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-06T04:49:00+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":49634,"pr_number":59310,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-06T04:49:00+00:00"},{"issue_number":52446,"pr_number":59310,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-06T04:49:00+00:00"}],"members":[{"kind":"issue","number":49634,"title":"bug(gateway): multiplex Telegram DM — responses route through wrong bot due to chat_id collision","url":null,"state":"closed"},{"kind":"pr","number":49962,"title":"fix(gateway): keep multiplex Telegram DMs on the owning bot","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8a9bc38c2e72a20ed1e8b081917b6a0dd8891573","number":0},{"kind":"pr","value":"#59310","number":59310}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":59310,"title":"fix(gateway): route multiplex profile responses through the profile's own adapter (#56854 salvage)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/authz_mixin.py","gateway/run.py","scripts/release.py","tests/gateway/test_notice_rendering.py","tests/gateway/test_queue_consumption.py"],"fix_hunks":[{"file":"gateway/authz_mixin.py","hunks":["@@ -47,10 +47,14 @@ def _authorization_adapter(\n         if not platform:\n             return None\n         profile_name = (profile or \"\").strip() or None\n-        if profile_name:\n+        if profile_name and profile_name != \"default\":\n             profile_adapters = getattr(self, \"_profile_adapters\", None) or {}\n             if profile_name in profile_adapters:\n                 return profile_adapters[profile_name].get(platform)\n+            # Fail closed: a stamped secondary profile with no registry entry\n+            # (e.g. its adapter failed to connect) must NOT fall back to the\n+            # default profile's adapter — that sends replies out the wrong bot.\n+            return None\n         adapters = getattr(self, \"adapters\", None) or {}\n         return adapters.get(platform)\n "]},{"file":"gateway/run.py","hunks":["@@ -5201,7 +5201,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n                         \"Approval response via plain text: session=%s verb=%s args=%r\",\n                         session_key, _verb, _normalized_args,\n                     )\n-                    _adapter = self.adapters.get(event.source.platform)\n+                    _adapter = self._adapter_for_source(event.source)\n                     if _adapter and _reply:\n                         _text, _eph_ttl = _adapter._unwrap_ephemeral(_reply)\n                         if _text:","@@ -6318,7 +6318,7 @@ async def _drain_startup_restore_queue(self) -> int:\n         while queue:\n             event = queue.pop(0)\n             source = getattr(event, \"source\", None)\n-            adapter = self.adapters.get(source.platform) if source is not None else None\n+            adapter = self._adapter_for_source(source)\n             if adapter is None:\n                 logger.debug(\n                     \"Dropping startup-restore queued message: adapter unavailable for %s\",","@@ -6424,7 +6424,7 @@ def _schedule_resume_pending_sessions(self, platform=None) -> int:\n                 continue\n \n             source = entry.origin\n-            adapter = self.adapters.get(source.platform)\n+            adapter = self._adapter_for_source(source)\n             if adapter is None:\n                 logger.debug(\n                     \"Skipping auto-resume for %s: adapter not ready for %s\",","@@ -8595,7 +8595,7 @@ def check(\n \n     async def _deliver_platform_notice(self, source, content: str) -> None:\n         \"\"\"Deliver a setup/operational notice using platform-specific privacy rules.\"\"\"\n-        adapter = self.adapters.get(source.platform)\n+        adapter = self._adapter_for_source(source)\n         if not adapter:\n             return\n ","@@ -9070,7 +9070,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                 has_media = bool(getattr(event, \"media_urls\", None))\n                 if not queued_text and not has_media:\n                     return \"Usage: /queue <prompt>\"\n-                adapter = self.adapters.get(source.platform)\n+                adapter = self._adapter_for_source(source)\n                 if adapter:\n                     queued_event = MessageEvent(\n                         text=queued_text,","@@ -9091,7 +9091,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                         timestamp=event.timestamp,\n                     )\n                     self._enqueue_fifo(_quick_key, queued_event, adapter)\n-                depth = self._queue_depth(_quick_key, adapter=self.adapters.get(source.platform))\n+                depth = self._queue_depth(_quick_key, adapter=self._adapter_for_source(source))\n                 if depth <= 1:\n                     return \"Queued for the next turn.\"\n                 return f\"Queued for the next turn. ({depth} queued)\"","@@ -9108,7 +9108,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                 running_agent = self._running_agents.get(_quick_key)\n                 if running_agent is _AGENT_PENDING_SENTINEL:\n                     # Agent hasn't started yet — queue as turn-boundary fallback.\n-                    adapter = self.adapters.get(source.platform)\n+                    adapter = self._adapter_for_source(source)\n                     if adapter:\n                         queued_event = MessageEvent(\n                             text=steer_text,","@@ -9130,7 +9130,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                         return f\"⏩ Steer queued — arrives after the next tool call: '{preview}'\"\n                     return \"Steer rejected (empty payload).\"\n                 # Running agent is missing or lacks steer() — fall back to queue.\n-                adapter = self.adapters.get(source.platform)\n+                adapter = self._adapter_for_source(source)\n                 if adapter:\n                     queued_event = MessageEvent(\n                         text=steer_text,","@@ -9255,7 +9255,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n \n             if event.message_type == MessageType.PHOTO:\n                 logger.debug(\"PRIORITY photo follow-up for session %s — queueing without interrupt\", _quick_key)\n-                adapter = self.adapters.get(source.platform)\n+                adapter = self._adapter_for_source(source)\n                 if adapter:\n                     merge_pending_message_event(adapter._pending_messages, _quick_key, event)\n                 return None","@@ -9276,7 +9276,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                     time.time() - _started_at,\n                     _quick_key,\n                 )\n-                adapter = self.adapters.get(source.platform)\n+                adapter = self._adapter_for_source(source)\n                 if adapter:\n                     if self._busy_input_mode == \"queue\":\n                         self._enqueue_fifo(_quick_key, event, adapter)","@@ -9299,7 +9299,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                     return EphemeralReply(\"⚡ Force-stopped. The agent was still starting — session unlocked.\")\n                 # Queue the message so it will be picked up after the\n                 # agent starts.\n-                adapter = self.adapters.get(source.platform)\n+                adapter = self._adapter_for_source(source)\n                 if adapter:\n                     merge_pending_message_event(\n                         adapter._pending_messages,","@@ -9538,7 +9538,7 @@ async def _do_reset():\n                 else \"Learning a skill from this conversation…\"\n             )\n             try:\n-                adapter = self.adapters.get(source.platform)\n+                adapter = self._adapter_for_source(source)\n                 if adapter:\n                     _ack_meta = self._thread_metadata_for_source(source)\n                     await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta)","@@ -9592,7 +9592,7 @@ async def _do_reset():\n                 _ack = getattr(_blueprint_result, \"text\", \"\") or \"\"\n                 if _ack:\n                     try:\n-                        adapter = self.adapters.get(source.platform)\n+                        adapter = self._adapter_for_source(source)\n                         if adapter:\n                             _ack_meta = self._thread_metadata_for_source(source)\n                             await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta)","@@ -10219,7 +10219,7 @@ async def _prepare_inbound_message_text(\n                 # while allowing quiet STT for users who only want the agent to\n                 # receive the transcription.\n                 if _successful_transcripts and self._should_echo_stt_transcripts():\n-                    _echo_adapter = self.adapters.get(source.platform)\n+                    _echo_adapter = self._adapter_for_source(source)\n                     _echo_meta = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))\n                     if _echo_adapter:\n                         for _tx in _successful_transcripts:","@@ -10393,7 +10393,7 @@ async def _prepare_inbound_message_text(\n                     allowed_root=_msg_cwd,\n                 )\n                 if _ctx_result.blocked:\n-                    _adapter = self.adapters.get(source.platform)\n+                    _adapter = self._adapter_for_source(source)\n                     if _adapter:\n                         await _adapter.send(\n                             source.chat_id,","@@ -10634,7 +10634,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                     and platform_name not in policy.notify_exclude_platforms\n                 )\n                 if should_notify:\n-                    adapter = self.adapters.get(source.platform)\n+                    adapter = self._adapter_for_source(source)\n                     if adapter:\n                         if reset_reason == \"suspended\":\n                             reason_text = \"previous session was stopped or interrupted\"","@@ -11029,7 +11029,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                                             \"configuration.\"\n                                         )\n                                         try:\n-                                            _adapter = self.adapters.get(source.platform)\n+                                            _adapter = self._adapter_for_source(source)\n                                             if _adapter and source.chat_id:\n                                                 await _adapter.send(source.chat_id, _warn_msg, metadata=_hyg_meta)\n                                         except Exception as _werr:","@@ -11053,7 +11053,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                                             \"check `auxiliary.compression.model` in config.yaml.\"\n                                         )\n                                         try:\n-                                            _adapter = self.adapters.get(source.platform)\n+                                            _adapter = self._adapter_for_source(source)\n                                             if _adapter and source.chat_id:\n                                                 await _adapter.send(source.chat_id, _aux_msg, metadata=_hyg_meta)\n                                         except Exception as _werr:","@@ -11210,7 +11210,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n         # event so deferred post-delivery callbacks can be released by the\n         # same run that registered them.\n         self._bind_adapter_run_generation(\n-            self.adapters.get(source.platform),\n+            self._adapter_for_source(source),\n             session_key,\n             run_generation,\n         )","@@ -11250,7 +11250,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n \n             # Stop persistent typing indicator now that the agent is done\n             try:\n-                _typing_adapter = self.adapters.get(source.platform)\n+                _typing_adapter = self._adapter_for_source(source)\n                 if _typing_adapter and hasattr(_typing_adapter, \"stop_typing\"):\n                     await _typing_adapter.stop_typing(source.chat_id)\n             except Exception:","@@ -11262,7 +11262,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                     _quick_key or \"?\",\n                     run_generation,\n                 )\n-                _stale_adapter = self.adapters.get(source.platform)\n+                _stale_adapter = self._adapter_for_source(source)\n                 if getattr(type(_stale_adapter), \"pop_post_delivery_callback\", None) is not None:\n                     _stale_adapter.pop_post_delivery_callback(\n                         _quick_key,","@@ -11772,7 +11772,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n             # users see the agent \"stop responding without explanation.\"\n             if agent_result.get(\"already_sent\") and not agent_result.get(\"failed\"):\n                 if response:\n-                    _media_adapter = self.adapters.get(source.platform)\n+                    _media_adapter = self._adapter_for_source(source)\n                     if _media_adapter:\n                         await self._deliver_media_from_response(\n                             response, event, _media_adapter,","@@ -11783,7 +11783,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 # still surface the runtime metadata on the final reply.\n                 if _footer_line:\n                     try:\n-                        _foot_adapter = self.adapters.get(source.platform)\n+                        _foot_adapter = self._adapter_for_source(source)\n                         if _foot_adapter:\n                             await _foot_adapter.send(\n                                 source.chat_id,","@@ -11799,7 +11799,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n         except Exception as e:\n             # Stop typing indicator on error too\n             try:\n-                _err_adapter = self.adapters.get(source.platform)\n+                _err_adapter = self._adapter_for_source(source)\n                 if _err_adapter and hasattr(_err_adapter, \"stop_typing\"):\n                     await _err_adapter.stop_typing(source.chat_id)\n             except Exception:","@@ -12304,7 +12304,7 @@ def _get_goal_manager_for_event(self, event: \"MessageEvent\"):\n \n     async def _send_goal_status_notice(self, source: Any, message: str) -> None:\n         \"\"\"Send a /goal judge status line back to the originating chat/thread.\"\"\"\n-        adapter = self.adapters.get(source.platform)\n+        adapter = self._adapter_for_source(source)\n         if not adapter:\n             logger.debug(\"goal continuation: no adapter for %s\", getattr(source, \"platform\", None))\n             return","@@ -12331,7 +12331,7 @@ async def _defer_goal_status_notice_after_delivery(self, source: Any, message: s\n         exactly this boundary; when unavailable, fall back to direct awaited\n         delivery rather than silently dropping the notice.\n         \"\"\"\n-        adapter = self.adapters.get(source.platform)\n+        adapter = self._adapter_for_source(source)\n         if not adapter:\n             logger.debug(\"goal continuation: no adapter for %s\", getattr(source, \"platform\", None))\n             return","@@ -12429,7 +12429,7 @@ async def _post_turn_goal_continuation(\n         # Enqueue via the adapter's FIFO so a user message already in\n         # flight preempts the continuation naturally.\n         try:\n-            adapter = self.adapters.get(source.platform)\n+            adapter = self._adapter_for_source(source)\n             _quick_key = self._session_key_for_source(source)\n             if adapter and _quick_key:\n                 cont_event = MessageEvent(","@@ -12462,7 +12462,7 @@ def _get_guild_id(event: MessageEvent) -> Optional[int]:\n \n     async def _handle_voice_channel_join(self, event: MessageEvent) -> str:\n         \"\"\"Join the user's current Discord voice channel.\"\"\"\n-        adapter = self.adapters.get(event.source.platform)\n+        adapter = self._adapter_for_source(event.source)\n         if not hasattr(adapter, \"join_voice_channel\"):\n             return \"Voice channels are not supported on this platform.\"\n ","@@ -12519,7 +12519,7 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str:\n \n     async def _handle_voice_channel_leave(self, event: MessageEvent) -> str:\n         \"\"\"Leave the Discord voice channel.\"\"\"\n-        adapter = self.adapters.get(event.source.platform)\n+        adapter = self._adapter_for_source(event.source)\n         guild_id = self._get_guild_id(event)\n \n         if not guild_id or not hasattr(adapter, \"leave_voice_channel\"):","@@ -12753,7 +12753,7 @@ async def _send_voice_reply(self, event: MessageEvent, text: str) -> None:\n                 logger.warning(\"Auto voice reply TTS failed: %s\", result.get(\"error\"))\n                 return\n \n-            adapter = self.adapters.get(event.source.platform)\n+            adapter = self._adapter_for_source(event.source)\n \n             # If connected to a voice channel, play there instead of sending a file\n             guild_id = self._get_guild_id(event)","@@ -12931,7 +12931,7 @@ async def _run_background_task(\n         media_urls = media_urls or []\n         media_types = media_types or []\n \n-        adapter = self.adapters.get(source.platform)\n+        adapter = self._adapter_for_source(source)\n         if not adapter:\n             logger.warning(\"No adapter for platform %s in background task %s\", source.platform, task_id)\n             return","@@ -13126,7 +13126,7 @@ def run_sync():\n \n     async def _get_telegram_topic_capabilities(self, source: SessionSource) -> dict:\n         \"\"\"Read Telegram private-topic capability flags via Bot API getMe.\"\"\"\n-        adapter = self.adapters.get(source.platform) if getattr(self, \"adapters\", None) else None\n+        adapter = self._adapter_for_source(source)\n         bot = getattr(adapter, \"_bot\", None)\n         if bot is None or not hasattr(bot, \"get_me\"):\n             return {\"checked\": False}","@@ -13154,7 +13154,7 @@ def _field(name: str):\n \n     async def _ensure_telegram_system_topic(self, source: SessionSource) -> None:\n         \"\"\"Create/pin the managed System topic after /topic activation when possible.\"\"\"\n-        adapter = self.adapters.get(source.platform) if getattr(self, \"adapters\", None) else None\n+        adapter = self._adapter_for_source(source)\n         if adapter is None or not source.chat_id:\n             return\n ","@@ -13195,7 +13195,7 @@ async def _ensure_telegram_system_topic(self, source: SessionSource) -> None:\n \n     async def _send_telegram_topic_setup_image(self, source: SessionSource) -> None:\n         \"\"\"Send the bundled BotFather Threads Settings screenshot when available.\"\"\"\n-        adapter = self.adapters.get(source.platform) if getattr(self, \"adapters\", None) else None\n+        adapter = self._adapter_for_source(source)\n         if adapter is None or not source.chat_id or not hasattr(adapter, \"send_image_file\"):\n             return\n         image_path = Path(__file__).resolve().parent / \"assets\" / \"telegram-botfather-threads-settings.jpg\"","@@ -13247,7 +13247,7 @@ async def _rename_telegram_topic_for_session_title(\n         # Check the class, not the instance — getattr() on MagicMock\n         # auto-creates attributes, so `hasattr(adapter, \"_get_dm_topic_info\")`\n         # would return True for every test double.\n-        adapter = self.adapters.get(source.platform) if getattr(self, \"adapters\", None) else None\n+        adapter = self._adapter_for_source(source)\n         if adapter is not None:\n             get_info = getattr(type(adapter), \"_get_dm_topic_info\", None)\n             if callable(get_info):","@@ -13806,7 +13806,7 @@ async def _request_slash_confirm(\n         # cannot race the send_slash_confirm return.\n         _slash_confirm_mod.register(session_key, confirm_id, command, handler)\n \n-        adapter = self.adapters.get(source.platform)\n+        adapter = self._adapter_for_source(source)\n         metadata = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))\n \n         used_buttons = False","@@ -14820,7 +14820,7 @@ async def _dequeue_pending_with_transcription(\n             # Echo raw transcripts back to the user when configured so voice\n             # interrupts feel identical to fresh voice messages.\n             if successful_transcripts and self._should_echo_stt_transcripts():\n-                echo_adapter = self.adapters.get(source.platform)\n+                echo_adapter = self._adapter_for_source(source)\n                 echo_meta = {\"thread_id\": source.thread_id} if source.thread_id else None\n                 if echo_adapter:\n                     for tx in successful_transcripts:","@@ -15642,7 +15642,7 @@ async def _interrupt_and_clear_session(\n         if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:\n             running_agent.interrupt(interrupt_reason)\n         self._invalidate_session_run_generation(session_key, reason=invalidation_reason)\n-        adapter = self.adapters.get(source.platform)\n+        adapter = self._adapter_for_source(source)\n         if adapter and hasattr(adapter, \"interrupt_session_activity\"):\n             await adapter.interrupt_session_activity(session_key, source.chat_id)\n         if adapter and hasattr(adapter, \"get_pending_message\"):","@@ -16207,7 +16207,7 @@ def _run_still_current() -> bool:\n         if _streaming_enabled:\n             try:\n                 from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig\n-                _adapter = self.adapters.get(source.platform)\n+                _adapter = self._adapter_for_source(source)\n                 if _adapter:\n                     _pause_typing_before_finalize = None\n                     if source.platform == Platform.TELEGRAM and hasattr(_adapter, \"pause_typing_for_chat\"):","@@ -16258,7 +16258,7 @@ def _pause_typing_before_finalize(\n             stream_task = asyncio.create_task(_stream_consumer.run())\n \n         # Send typing indicator\n-        _adapter = self.adapters.get(source.platform)\n+        _adapter = self._adapter_for_source(source)\n         if _adapter:\n             try:\n                 await _adapter.send_typing(source.chat_id, metadata=_thread_metadata)","@@ -16698,7 +16698,7 @@ def voice_ack_callback(call_id, tool_name, args):\n         _cleanup_progress = bool(\n             resolve_display_setting(user_config, platform_key, \"cleanup_progress\")\n         )\n-        _cleanup_adapter = self.adapters.get(source.platform) if _cleanup_progress else None\n+        _cleanup_adapter = self._adapter_for_source(source) if _cleanup_progress else None\n         if _cleanup_adapter is not None and (\n             type(_cleanup_adapter).delete_message is BasePlatformAdapter.delete_message\n         ):","@@ -16819,7 +16819,7 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non\n             _code_block_full = None\n             _code_block_short = None\n             try:\n-                _progress_adapter = self.adapters.get(source.platform)\n+                _progress_adapter = self._adapter_for_source(source)\n             except Exception:\n                 _progress_adapter = None\n             if (","@@ -17009,7 +17009,7 @@ async def send_progress_messages():\n             if not progress_queue:\n                 return\n \n-            adapter = self.adapters.get(source.platform)\n+            adapter = self._adapter_for_source(source)\n             if not adapter:\n                 return\n ","@@ -17384,7 +17384,7 @@ def _event_callback_sync(event_type: str, context: dict) -> None:\n                 logger.debug(\"event_callback hook error: %s\", _e)\n \n         # Bridge sync status_callback → async adapter.send for context pressure\n-        _status_adapter = self.adapters.get(source.platform)\n+        _status_adapter = self._adapter_for_source(source)\n         _status_chat_id = source.chat_id\n         if source.platform == Platform.FEISHU and source.thread_id and event_message_id:\n             # Feishu topics only keep messages inside the topic when they are","@@ -17530,7 +17530,7 @@ def run_sync():\n             if _want_stream_deltas or _want_interim_consumer:\n                 try:\n                     from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig\n-                    _adapter = self.adapters.get(source.platform)\n+                    _adapter = self._adapter_for_source(source)\n                     if _adapter:\n                         _pause_typing_before_finalize = None\n                         if source.platform == Platform.TELEGRAM and hasattr(_adapter, \"pause_typing_for_chat\"):","@@ -18698,7 +18698,7 @@ async def monitor_for_interrupt():\n                 try:\n                     # Re-resolve adapter each iteration so reconnects don't\n                     # leave us holding a stale reference.\n-                    _adapter = self.adapters.get(source.platform)\n+                    _adapter = self._adapter_for_source(source)\n                     if not _adapter:\n                         continue\n                     # Check if adapter has a pending interrupt for this session.","@@ -18793,7 +18793,7 @@ async def monitor_for_interrupt():\n         async def _notify_long_running():\n             if _NOTIFY_INTERVAL is None:\n                 return  # Notifications disabled (gateway_notify_interval: 0)\n-            _notify_adapter = self.adapters.get(source.platform)\n+            _notify_adapter = self._adapter_for_source(source)\n             if not _notify_adapter:\n                 return\n             # Track the heartbeat message id so we can edit-in-place on","@@ -18938,7 +18938,7 @@ def _stream_confirmed_final_delivery(\n                     # Backup interrupt check: if the monitor task died or\n                     # missed the interrupt, catch it here.\n                     if not _interrupt_detected.is_set() and session_key:\n-                        _backup_adapter = self.adapters.get(source.platform)\n+                        _backup_adapter = self._adapter_for_source(source)\n                         _backup_agent = agent_holder[0]\n                         if (_backup_adapter and _backup_agent\n                                 and hasattr(_backup_adapter, 'has_pending_interrupt')","@@ -18978,7 +18978,7 @@ def _stream_confirmed_final_delivery(\n                     if (not _warning_fired and _agent_warning is not None\n                             and _idle_secs >= _agent_warning):\n                         _warning_fired = True\n-                        _warn_adapter = self.adapters.get(source.platform)\n+                        _warn_adapter = self._adapter_for_source(source)\n                         if _warn_adapter:\n                             _elapsed_warn = int(_agent_warning // 60) or 1\n                             _remaining_mins = int((_agent_timeout - _agent_warning) // 60) or 1","@@ -18998,7 +18998,7 @@ def _stream_confirmed_final_delivery(\n                         break\n                     # Backup interrupt check (same as unlimited path).\n                     if not _interrupt_detected.is_set() and session_key:\n-                        _backup_adapter = self.adapters.get(source.platform)\n+                        _backup_adapter = self._adapter_for_source(source)\n                         _backup_agent = agent_holder[0]\n                         if (_backup_adapter and _backup_agent\n                                 and hasattr(_backup_adapter, 'has_pending_interrupt')","@@ -19115,7 +19115,7 @@ def _stream_confirmed_final_delivery(\n \n             # Check if we were interrupted OR have a queued message (/queue).\n             result = result_holder[0]\n-            adapter = self.adapters.get(source.platform)\n+            adapter = self._adapter_for_source(source)\n             \n             # Get pending message from adapter.\n             # Use session_key (not source.chat_id) to match adapter's storage keys.","@@ -19246,7 +19246,7 @@ def _stream_confirmed_final_delivery(\n                         \"queueing message instead of recursing.\",\n                         _interrupt_depth, session_key,\n                     )\n-                    adapter = self.adapters.get(source.platform)\n+                    adapter = self._adapter_for_source(source)\n                     if adapter and pending_event:\n                         merge_pending_message_event(adapter._pending_messages, session_key, pending_event)\n                     elif adapter and hasattr(adapter, 'queue_message'):","@@ -19365,7 +19365,7 @@ def _stream_confirmed_final_delivery(\n                 # Restart typing indicator so the user sees activity while\n                 # the follow-up turn runs.  The outer _process_message_background\n                 # typing task is still alive but may be stale.\n-                _followup_adapter = self.adapters.get(source.platform)\n+                _followup_adapter = self._adapter_for_source(source)\n                 if _followup_adapter:\n                     try:\n                         await _followup_adapter.send_typing("]},{"file":"scripts/release.py","hunks":["@@ -46,6 +46,8 @@\n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"derek2000139\",  # PR #57838 salvage (desktop/windows: pre-write update marker before quit dwell so the renderer's waitForUpdateToFinish gate parks instead of respawning a backend that re-locks venv .pyd files mid-update)\n+    \"AndreasHiltner@users.noreply.github.com\": \"AndreasHiltner\",  # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep)\n+    \"[redacted:email]\": \"ManniBr\",  # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles)\n     \"[redacted:email]\": \"s905060\",  # PR #57943 salvage (photon: auto-reinstall stale sidecar node_modules when lockfile is newer than npm's install marker; #59169)\n     \"[redacted:email]\": \"l0h1nth\",  # PR #32210 salvage (mattermost: accept leading-space slash commands from mobile clients; #25184)\n     \"[redacted:email]\": \"devatnull\",  # PR #58704 salvage (whatsapp: native Baileys polls, clarify-as-poll, location pins, structured quoted replies, PTT/audio split, bridge_helpers extraction)"]},{"file":"tests/gateway/test_notice_rendering.py","hunks":["@@ -107,6 +107,10 @@ def _make_source(platform_value=\"telegram\", chat_id=\"555\", user_id=\"u1\"):\n     src.platform = plat\n     src.chat_id = chat_id\n     src.user_id = user_id\n+    # Real SessionSource.profile is None (single-profile) or a str; a MagicMock\n+    # auto-attribute would read as a truthy \"stamped profile\" and trip the\n+    # fail-closed path in _adapter_for_source (see AGENTS.md pitfall #17).\n+    src.profile = None\n     return src\n \n "]},{"file":"tests/gateway/test_queue_consumption.py","hunks":["@@ -382,7 +382,9 @@ def _make_runner_and_adapter(self):\n         return runner, adapter\n \n     def _text_event(self, text: str) -> MessageEvent:\n-        source = MagicMock(chat_id=\"c1\", platform=Platform.TELEGRAM)\n+        # profile=None: a MagicMock auto-attribute reads as a truthy stamped\n+        # profile and trips fail-closed adapter resolution (AGENTS.md #17).\n+        source = MagicMock(chat_id=\"c1\", platform=Platform.TELEGRAM, profile=None)\n         return MessageEvent(\n             text=text,\n             message_type=MessageType.TEXT,","@@ -433,7 +435,7 @@ def test_photo_burst_still_merges_in_head_slot(self):\n         runner, adapter = self._make_runner_and_adapter()\n         session_key = \"telegram:user:burst\"\n \n-        source = MagicMock(chat_id=\"c1\", platform=Platform.TELEGRAM)\n+        source = MagicMock(chat_id=\"c1\", platform=Platform.TELEGRAM, profile=None)\n         for i in range(3):\n             runner._queue_or_replace_pending_event(\n                 session_key,\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}