fix(relay): route Discord tool-progress into the auto-thread, not the parent channel (#77830)

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).
This commit is contained in:
Ben Barclay 2026-08-03 08:58:15 -07:00 committed by GitHub
parent 003b4c8893
commit 2f09df5615
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 151 additions and 1 deletions

View File

@ -24356,6 +24356,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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
@ -24367,10 +24384,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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
)
@ -24491,6 +24517,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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

View File

@ -436,6 +436,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)
# ---------------------------------------------------------------------------