diff --git a/gateway/run.py b/gateway/run.py index 6480755876f2..8eb18825bca2 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -23855,6 +23855,23 @@ def _generic_status_phrase(kind: str, *, tool_name: str | None = None, preview: source.platform, source.thread_id, event_message_id, reply_in_thread=_progress_reply_in_thread, ) + # Relay Discord auto-thread lane: a channel-initiating message has no + # thread_id at ingest (the thread is born on the connector's FIRST + # send). The connector stamps prospective_thread_id (the anchor message + # id, == the id of the thread it will create) and auto-threads any + # outbound carrying that anchor as reply_to. Without it, the progress / + # tool-status bubble is sent flat (no thread, no anchor) and lands in + # the PARENT channel while the final reply threads — the search-status + # updates leaked outside the thread (staging repro 2026-08-02). Carry + # the anchor on the progress send so it routes into the SAME auto-thread. + _relay_prospective_thread_id = ( + str(getattr(source, "prospective_thread_id", None)) + if source.platform == Platform.DISCORD + and getattr(source, "delivered_via_upstream_relay", False) + and getattr(source, "prospective_thread_id", None) + and not source.thread_id + else None + ) _progress_metadata = ( self._thread_metadata_for_source(source, event_message_id) if _progress_thread_id == source.thread_id @@ -23866,10 +23883,19 @@ def _generic_status_phrase(kind: str, *, tool_name: str | None = None, preview: reply_to_message_id=event_message_id, ) ) if _progress_thread_id else None + if _progress_metadata is None and _relay_prospective_thread_id: + # No real thread yet, but the connector will auto-thread on the + # reply anchor; carry it so progress joins that thread. + _progress_metadata = {"reply_to_message_id": event_message_id} _progress_metadata = _non_conversational_metadata(_progress_metadata, platform=source.platform) _progress_reply_to = ( event_message_id - if source.platform in (Platform.FEISHU, Platform.MATTERMOST) and source.thread_id and event_message_id + if ( + source.platform in (Platform.FEISHU, Platform.MATTERMOST) + and source.thread_id + and event_message_id + ) + or _relay_prospective_thread_id else None ) @@ -23990,6 +24016,13 @@ async def write_tool_log(): reply_to_message_id=event_message_id, ) ) if _progress_thread_id else None + if _status_thread_metadata is None and _relay_prospective_thread_id: + # Relay Discord auto-thread lane (see _progress_metadata above): + # carry the reply anchor so status/interim bubbles route into + # the same connector-created thread as the final reply. + _status_thread_metadata = { + "reply_to_message_id": event_message_id + } # Bridge extracted to TurnRunner._status_callback_sync; publish the # status wiring computed above onto the shared TurnContext at the diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 1cd5da9e88c9..3cbf50c84780 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -402,6 +402,123 @@ async def test_run_agent_progress_uses_event_message_id_for_slack_dm(monkeypatch assert all(call["metadata"] == expected_metadata for call in adapter.typing) +@pytest.mark.asyncio +async def test_progress_carries_anchor_for_relay_discord_auto_thread(monkeypatch, tmp_path): + """Relay Discord channel-initiate: the thread doesn't exist at ingest, so + the connector auto-threads on the reply anchor and stamps + prospective_thread_id. The tool-progress / status bubbles must carry that + anchor (reply_to + metadata.reply_to_message_id) so they route into the + SAME auto-thread as the final reply — otherwise the search-status updates + leak into the parent channel (staging repro 2026-08-02).""" + monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") + import yaml + (tmp_path / "config.yaml").write_text( + yaml.dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}), + encoding="utf-8", + ) + + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *args, **kwargs: None + monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) + + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = FakeAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + + adapter = ProgressCaptureAdapter(platform=Platform.RELAY) + runner = _make_runner(adapter) + gateway_run = importlib.import_module("gateway.run") + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}) + + # Channel-initiating message: no thread_id yet, but the connector stamped + # the prospective thread id (== the triggering message id). Relay ingress + # keeps the underlying platform (discord) on the source for display policy, + # but delivery/progress route through the one live RelayAdapter. + source = SessionSource( + platform=Platform.DISCORD, + chat_id="chan-parent", + chat_type="group", + thread_id=None, + prospective_thread_id="msg-anchor-1", + delivered_via_upstream_relay=True, + ) + + result = await runner._run_agent( + message="find me a gift", + context_prompt="", + history=[], + source=source, + session_id="sess-relay-thread", + session_key="agent:main:discord:thread:chan-parent:msg-anchor-1", + event_message_id="msg-anchor-1", + ) + + assert result["final_response"] == "done" + assert adapter.sent, "expected at least one progress send" + # Every progress send must carry the anchor so the connector threads it. + for call in adapter.sent: + assert call["reply_to"] == "msg-anchor-1", call + assert (call["metadata"] or {}).get("reply_to_message_id") == "msg-anchor-1", call + # Discord lifecycle/status sends are marked non-conversational. + assert (call["metadata"] or {}).get("non_conversational") is True, call + + +@pytest.mark.asyncio +async def test_progress_no_anchor_for_native_discord_thread_event(monkeypatch, tmp_path): + """A message ARRIVING in an existing Discord thread (not the relay + auto-thread lane) must NOT get the synthetic prospective anchor — it already + routes by its real thread. Guards against over-broadening the relay fix.""" + monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") + import yaml + (tmp_path / "config.yaml").write_text( + yaml.dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}), + encoding="utf-8", + ) + + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *args, **kwargs: None + monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) + + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = FakeAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + + adapter = ProgressCaptureAdapter(platform=Platform.RELAY) + runner = _make_runner(adapter) + gateway_run = importlib.import_module("gateway.run") + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}) + + # No prospective_thread_id (event is IN a real thread already). + source = SessionSource( + platform=Platform.DISCORD, + chat_id="real-thread-9", + chat_type="thread", + thread_id="real-thread-9", + delivered_via_upstream_relay=True, + ) + + result = await runner._run_agent( + message="continue", + context_prompt="", + history=[], + source=source, + session_id="sess-in-thread", + session_key="agent:main:discord:thread:real-thread-9:real-thread-9", + event_message_id="msg-2", + ) + + assert result["final_response"] == "done" + # The relay-prospective synthetic anchor path must NOT engage; progress + # routes by the real thread's own metadata, not a forced reply_to anchor. + for call in adapter.sent: + meta = call["metadata"] or {} + # The real thread id drives routing; we did not inject the anchor + # reply_to that the prospective lane uses. + assert meta.get("thread_id") == "real-thread-9" or call["reply_to"] != "msg-2", call + + # --------------------------------------------------------------------------- # Preview truncation tests (all/new mode respects tool_preview_length) # ---------------------------------------------------------------------------