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:
parent
003b4c8893
commit
2f09df5615
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue