Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)

Expand Down Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions tests/gateway/test_run_progress_topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
Loading