NousResearch/hermes-agent

Gateway multiplex Telegram responses routed through wrong bot

Cluster 49634 · gateway. Data as of

Nothing open on GitHub. Fix merged in #59310 on 6 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #59310. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #59310 fix(gateway): route multiplex profile responses through the profile's own adapter (#56854 salvage)

    merged to main

    files: gateway/authz_mixin.py, gateway/run.py, scripts/release.py, tests/gateway/test_notice_rendering.py, tests/gateway/test_queue_consumption.py

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

1 PR closed without merging

From the close comments: done elsewhere 1

  • #49962 fix(gateway): keep multiplex Telegram DMs on the owning bot · done elsewhere → 8a9bc38, #59310
Closed issues · 1
  • Closed#49634bug(gateway): multiplex Telegram DM — responses route through wrong bot due to chat_id collision
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

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 Lab

At a glance

Subsystem
gateway
Merged PRs
#59310
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
6 Jul 2026

For your agent

The same facts, typed.