From 26aaf4ffac6eb3b797620725e2a49a1a6e0ffe6d Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Mon, 3 Aug 2026 09:49:34 -0600 Subject: [PATCH] fix(relay): route Discord tool-progress into the auto-thread, not the parent channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Discord channel message initiates a relay auto-thread, the thread does not exist at ingest (source.thread_id is None) — the connector creates it on its FIRST send and auto-threads any outbound carrying the reply anchor. The final reply carries that anchor, so it lands in the thread. But the tool-progress / status bubbles (the "Searching the web for..." updates and the streaming preamble) were sent with _progress_metadata=None and _progress_reply_to=None: _resolve_progress_thread_id returns None for Discord (only slack/mattermost get a synthetic thread), so the progress send had no anchor and the connector posted it FLAT in the parent channel. Result: the search-status updates leaked outside the thread while the answer threaded (staging repro 2026-08-02). The connector now stamps prospective_thread_id on the inbound (the anchor message id == the id of the thread it will create). Reuse it: when a relay-delivered Discord channel-initiate carries prospective_thread_id and has no real thread yet, carry the reply anchor (event_message_id) on both the progress metadata (reply_to_message_id) and the progress reply_to, so the connector routes the progress bubble into the SAME auto-thread as the final reply. Applied to both the tool-progress path (_progress_metadata / _progress_reply_to) and the status/interim callback path (_status_thread_metadata). Events already arriving in a real thread, DMs, and non-relay sources are untouched (guarded on delivered_via_upstream_relay + prospective_thread_id + not thread_id). Tests: two new cases in test_run_progress_topics.py — a relay Discord channel-initiate asserts every progress send carries the anchor (reply_to + metadata.reply_to_message_id + non_conversational), and an event already in a real thread asserts the synthetic-anchor path does NOT engage. Full gateway progress + relay + session suites green (228 passed). --- gateway/run.py | 35 ++++++- tests/gateway/test_run_progress_topics.py | 117 ++++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) 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) # ---------------------------------------------------------------------------