Files
hermes-agent/tests/gateway/relay/test_relay_threads.py
T
Brooklyn Nicholson 34577fcb03 fix(gateway): rename a Discord thread once, after the reply lands
Titling is two-stage — a slice of the user's own words lands inline, the
model's version replaces it a second later — and the platform rename lanes
fired on both. That is two rate-limited calls to reach one name, and
Discord allows two channel renames per ten minutes, so the throwaway could
be the one that survived. The callback now carries which stage it is, and
the lanes take the model's.

The relay lane also asked where the reply landed at title time, which is
before the model has answered: it polled the send-result cache for ten
seconds and read the timeout as "never auto-threaded", so any turn with
tool calls in it silently kept its raw thread name. Wait on the send
itself instead — the adapter already owns that cache, so it can say when a
reply arrives and, just as usefully, that one arrived carrying nothing.
2026-08-09 04:33:58 -05:00

561 lines
20 KiB
Python

"""Relay Phase 4 tests — thread lifecycle ops, reply_to enrichment parse,
auto-thread markers, and the hello command manifest.
Covers:
- create_handoff_thread routes through `thread_create` (op-gated; None
fallback contract preserved for the handoff watcher);
- rename_thread routes through `thread_rename` with the
only_if_current_name guard on the wire (op-gated; False on decline);
- the relay semantic-rename lane parity: a relay source carrying the
connector-stamped auto-thread markers satisfies the same field contract
the native _is_discord_auto_thread_lane reads;
- _event_from_wire maps reply_to {text,author,is_own} onto the native
MessageEvent reply-context fields and the auto-thread markers onto
SessionSource;
- the ws transport sends command_manifest on the DISCORD hello only;
- the manifest builder satisfies Discord CHAT_INPUT naming rules.
"""
from __future__ import annotations
import re
from typing import Any, Dict
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.relay.adapter import RelayAdapter
from gateway.relay.command_manifest import build_relay_command_manifest
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from gateway.relay.ws_transport import _event_from_wire
from tests.gateway.relay.stub_connector import StubConnector
FULL_OPS = (
"send",
"edit",
"typing",
"get_chat_info",
"thread_create",
"thread_rename",
)
def make_desc(**kw) -> CapabilityDescriptor:
base = dict(
contract_version=CONTRACT_VERSION,
platform="discord",
label="Discord",
max_message_length=2000,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=True,
markdown_dialect="discord",
len_unit="chars",
supported_ops=FULL_OPS,
)
base.update(kw)
return CapabilityDescriptor(**base)
def _adapter(**desc_kw) -> tuple[RelayAdapter, StubConnector]:
stub = StubConnector(make_desc(**desc_kw))
adapter = RelayAdapter(PlatformConfig(), make_desc(**desc_kw), transport=stub)
return adapter, stub
# ── thread_create (handoff) ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_handoff_thread_routes_thread_create():
adapter, stub = _adapter()
stub.next_send_result = {"success": True} # unused; thread op has own arm
async def send_outbound(action, *, platform=None):
stub.sent.append(action)
stub.sent_platforms.append(platform)
return {"success": True, "thread_id": "th77"}
stub.send_outbound = send_outbound # type: ignore[method-assign]
thread_id = await adapter.create_handoff_thread("chan1", "fix the build")
assert thread_id == "th77"
action = stub.sent[-1]
assert action["op"] == "thread_create"
assert action["chat_id"] == "chan1"
assert action["thread_name"] == "fix the build"
# ── rename_thread (semantic rename) ──────────────────────────────────────
@pytest.mark.asyncio
async def test_rename_thread_carries_the_no_clobber_guard():
adapter, stub = _adapter()
ok = await adapter.rename_thread(
"th1", "Fix the build", only_if_current_name="Hermes"
)
assert ok is True
action = stub.sent[-1]
assert action["op"] == "thread_rename"
assert action["message_id"] == "th1"
assert action["thread_name"] == "Fix the build"
assert action["only_if_current_name"] == "Hermes"
# chat_id defaults to the thread id (Discord ignores it; Telegram callers
# pass parent_chat_id explicitly).
assert action["chat_id"] == "th1"
@pytest.mark.asyncio
async def test_rename_thread_parent_chat_and_gating():
adapter, stub = _adapter()
await adapter.rename_thread("42", "topic", parent_chat_id="-100999")
assert stub.sent[-1]["chat_id"] == "-100999"
assert "only_if_current_name" not in stub.sent[-1]
gated, gated_stub = _adapter(supported_ops=("send",))
assert await gated.rename_thread("42", "x") is False
assert gated_stub.sent == []
@pytest.mark.asyncio
async def test_rename_thread_prefers_connector_owned_guard():
"""The relay lane sends only_if_connector_created (connector resolves the
no-clobber guard from its own created-name memory) instead of the fragile
cross-repo only_if_current_name string."""
adapter, stub = _adapter()
ok = await adapter.rename_thread(
"th9", "Real Session Title", prefer_connector_created=True
)
assert ok is True
action = stub.sent[-1]
assert action["op"] == "thread_rename"
assert action["only_if_connector_created"] is True
# The fragile string guard is NOT sent when the connector owns the check.
assert "only_if_current_name" not in action
@pytest.mark.asyncio
async def test_rename_thread_connector_guard_takes_precedence_over_string():
"""prefer_connector_created wins even if a legacy string is also passed."""
adapter, stub = _adapter()
await adapter.rename_thread(
"th9",
"Title",
prefer_connector_created=True,
only_if_current_name="ignored initial words",
)
action = stub.sent[-1]
assert action["only_if_connector_created"] is True
assert "only_if_current_name" not in action
@pytest.mark.asyncio
async def test_rename_thread_resolves_scope_from_parent_chat_not_thread():
"""The connector's egress guard resolves the owning tenant from the
outbound metadata's scope_id / user_id, and the adapter's discriminator
caches are keyed by the PARENT channel chat_id (learned at inbound), never
the thread id. A rename that passes parent_chat_id must carry that
discriminator; a rename keyed only on the thread id must not — reproducing
the live decline ("target not routed to an onboarded tenant") and its fix.
"""
adapter, stub = _adapter()
# Simulate the inbound-learned scope for the PARENT channel only.
adapter._scope_by_chat["chan-parent"] = "guild-123"
# Fix: pass the parent chat id -> scope_id resolves.
await adapter.rename_thread(
"th-9",
"Real Title",
prefer_connector_created=True,
parent_chat_id="chan-parent",
)
fixed = stub.sent[-1]
assert fixed["metadata"].get("scope_id") == "guild-123"
# Regression shape: keyed on the thread id alone (no parent) -> no scope_id,
# which is exactly what made the connector decline the op.
await adapter.rename_thread(
"th-9",
"Real Title",
prefer_connector_created=True,
)
unscoped = stub.sent[-1]
assert "scope_id" not in unscoped["metadata"]
# ── the relay semantic-rename lane (marker parity) ───────────────────────
# ── reply_to wire parse ──────────────────────────────────────────────────
def test_event_from_wire_reply_to_absent_and_partial():
plain = _event_from_wire(
{
"text": "hi",
"message_type": "text",
"source": {"platform": "telegram", "chat_id": "5", "chat_type": "dm"},
}
)
assert plain.reply_to_text is None
assert plain.reply_to_is_own_message is False
partial = _event_from_wire(
{
"text": "re",
"message_type": "text",
"source": {"platform": "whatsapp", "chat_id": "1", "chat_type": "dm"},
"reply_to_message_id": "wamid.x",
"reply_to": {"author": "Alice"}, # text leg missed the cache
}
)
assert partial.reply_to_author_name == "Alice"
assert partial.reply_to_text is None
# ── hello command manifest ───────────────────────────────────────────────
# ── auto-thread routing feedback (send-result thread_id) ─────────────────
@pytest.mark.asyncio
async def test_send_captures_auto_thread_feedback():
"""A send result carrying thread_id + auto_thread_name (the connector's
auto-thread egress policy routed the reply into a thread it created)
populates auto_thread_info_for_chat for the semantic-rename lane."""
adapter, stub = _adapter()
async def send_outbound(action, *, platform=None):
stub.sent.append(action)
return {
"success": True,
"message_id": "m1",
"thread_id": "th-auto-1",
"auto_thread_name": "What is a duck",
}
stub.send_outbound = send_outbound # type: ignore[method-assign]
result = await adapter.send("chan1", "quack")
assert result.success
assert adapter.auto_thread_info_for_chat("chan1") == (
"th-auto-1",
"What is a duck",
)
# Plain results (no auto-thread) leave no feedback for other chats.
assert adapter.auto_thread_info_for_chat("chan-other") is None
@pytest.mark.asyncio
async def test_send_without_thread_feedback_leaves_no_info():
adapter, stub = _adapter()
async def send_outbound(action, *, platform=None):
return {"success": True, "message_id": "m2"}
stub.send_outbound = send_outbound # type: ignore[method-assign]
await adapter.send("chan2", "hello")
assert adapter.auto_thread_info_for_chat("chan2") is None
@pytest.mark.asyncio
async def test_waiting_for_auto_thread_feedback_outlasts_the_turn():
"""The rename lane asks where the reply landed before the reply exists.
Titling reads the user's opening message, so the question arrives one whole
turn early — and a turn is however long the agent takes. Waiting on the send
rather than on a fixed nap is what makes the answer arrive at all.
"""
import asyncio
adapter, stub = _adapter()
async def send_outbound(action, *, platform=None):
return {
"success": True,
"message_id": "m1",
"thread_id": "th-auto-1",
"auto_thread_name": "What is a duck",
}
stub.send_outbound = send_outbound # type: ignore[method-assign]
waiting = asyncio.ensure_future(adapter.wait_for_auto_thread_info("chan1", 10.0))
await asyncio.sleep(0)
assert not waiting.done()
await adapter.send("chan1", "quack")
assert await waiting == ("th-auto-1", "What is a duck")
@pytest.mark.asyncio
async def test_a_reply_that_was_not_auto_threaded_reports_its_miss_on_send():
"""A miss is an answer, and the send is when we have it.
Only a turn that never sends at all should reach the timeout, so a policy
that doesn't auto-thread costs a wait as long as the turn, not as long as
the backstop.
"""
import asyncio
adapter, stub = _adapter()
async def send_outbound(action, *, platform=None):
return {"success": True, "message_id": "m1"}
stub.send_outbound = send_outbound # type: ignore[method-assign]
waiting = asyncio.ensure_future(adapter.wait_for_auto_thread_info("chan1", 600.0))
await asyncio.sleep(0)
await adapter.send("chan1", "quack")
assert await waiting is None
@pytest.mark.asyncio
async def test_waiting_for_auto_thread_feedback_gives_up():
"""A turn that never sends anything still has to end."""
adapter, _stub = _adapter()
assert await adapter.wait_for_auto_thread_info("chan-quiet", 0.05) is None
@pytest.mark.asyncio
async def test_auto_thread_feedback_is_bounded():
adapter, stub = _adapter()
async def send_outbound(action, *, platform=None):
return {
"success": True,
"message_id": "m",
"thread_id": f"th-{action['chat_id']}",
"auto_thread_name": "n",
}
stub.send_outbound = send_outbound # type: ignore[method-assign]
for i in range(300):
await adapter.send(f"c{i}", "x")
assert len(adapter._auto_thread_by_chat) <= 256
# Newest entries survive the bound.
assert adapter.auto_thread_info_for_chat("c299") == ("th-c299", "n")
# ── title-turn rename: registration shape-gate + fire-time cache poll ────
def _mk_runner_stub():
"""Minimal object carrying the three GatewayRunner methods under test."""
import asyncio as _asyncio
from gateway.run import GatewayRunner
class _Stub:
_is_relay_discord_channel_lane = GatewayRunner._is_relay_discord_channel_lane
_relay_auto_thread_info = GatewayRunner._relay_auto_thread_info
_await_relay_auto_thread_info = GatewayRunner._await_relay_auto_thread_info
_is_discord_auto_thread_lane = GatewayRunner._is_discord_auto_thread_lane
_sanitize_discord_thread_title = GatewayRunner._sanitize_discord_thread_title
_rename_discord_auto_thread_for_session_title = (
GatewayRunner._rename_discord_auto_thread_for_session_title
)
def __init__(self, adapter):
self.adapters = {Platform.RELAY: adapter}
def _adapter_for_source(self, source):
return self.adapters.get(Platform.RELAY)
return _Stub
def _relay_channel_source():
from types import SimpleNamespace
return SimpleNamespace(
platform=Platform.DISCORD,
chat_id="chan-parent",
chat_type="group",
thread_id=None,
delivered_via_upstream_relay=True,
auto_thread_created=False,
auto_thread_initial_name=None,
)
def test_relay_channel_lane_shape_gate():
from types import SimpleNamespace
from gateway.config import Platform as P
stub = _mk_runner_stub()(adapter=None)
src = _relay_channel_source()
assert stub._is_relay_discord_channel_lane(src) is True
# thread events, DMs, and native (non-relay) events do not match
assert (
stub._is_relay_discord_channel_lane(
SimpleNamespace(**{**src.__dict__, "thread_id": "t1"})
)
is False
)
assert (
stub._is_relay_discord_channel_lane(
SimpleNamespace(**{**src.__dict__, "chat_type": "dm"})
)
is False
)
assert (
stub._is_relay_discord_channel_lane(
SimpleNamespace(**{**src.__dict__, "delivered_via_upstream_relay": False})
)
is False
)
@pytest.mark.asyncio
async def test_relay_auto_thread_info_prefers_prospective_thread_id():
"""When the connector stamps prospective_thread_id, the rename lane uses it
directly (deterministic, per-thread) and does NOT consult the per-chat
send-result cache — the empty initial-name marker defers no-clobber to the
connector's own created-name guard."""
from types import SimpleNamespace
adapter, _ = _adapter()
# Poison the per-chat cache with a DIFFERENT (stale sibling) thread to prove
# the prospective id wins and the cache is not read.
adapter._auto_thread_by_chat["chan-parent"] = ("th-STALE", "old words")
runner = _mk_runner_stub()(adapter)
src = SimpleNamespace(
**{**_relay_channel_source().__dict__, "prospective_thread_id": "th-B"}
)
assert runner._relay_auto_thread_info(src) == ("th-B", "")
@pytest.mark.asyncio
async def test_sibling_threads_in_one_channel_each_rename_to_own_thread():
"""Two auto-threads spawned from the SAME parent channel must each rename
to their OWN thread id. Before the prospective_thread_id fix the per-chat
cache held one slot, so only the first thread renamed (staging repro
2026-08-02: thread A renamed, sibling thread B stuck at raw text)."""
from types import SimpleNamespace
adapter, _ = _adapter()
renames: list = []
async def rename_thread(
thread_id,
name,
*,
only_if_current_name=None,
prefer_connector_created=False,
parent_chat_id=None,
):
renames.append((thread_id, name, prefer_connector_created, parent_chat_id))
return True
adapter.rename_thread = rename_thread # type: ignore[method-assign]
runner = _mk_runner_stub()(adapter)
base = _relay_channel_source().__dict__
# A and B share the parent channel but carry distinct prospective thread ids.
src_a = SimpleNamespace(**{**base, "prospective_thread_id": "th-A"})
src_b = SimpleNamespace(**{**base, "prospective_thread_id": "th-B"})
await runner._rename_discord_auto_thread_for_session_title(
src_a, "sessA", "Sea Shanty Draft"
)
await runner._rename_discord_auto_thread_for_session_title(
src_b, "sessB", "Exotic Short Story"
)
# Each renamed ITS OWN thread, via the connector-owned guard, passing the
# parent channel id for tenant discriminator resolution.
assert renames == [
("th-A", "Sea Shanty Draft", True, "chan-parent"),
("th-B", "Exotic Short Story", True, "chan-parent"),
]
@pytest.mark.asyncio
async def test_title_rename_waits_for_feedback_that_arrives_late():
"""The title beats delivery by a whole turn, and the lane still renames.
Feedback only exists once the reply is sent, so the lane waits on the send
rather than on a nap long enough to cover a turn it can't measure.
"""
import asyncio
adapter, stub_conn = _adapter()
renames: list = []
async def rename_thread(
thread_id,
name,
*,
only_if_current_name=None,
prefer_connector_created=False,
parent_chat_id=None,
):
renames.append((thread_id, name, prefer_connector_created, parent_chat_id))
return True
adapter.rename_thread = rename_thread # type: ignore[method-assign]
runner = _mk_runner_stub()(adapter)
src = _relay_channel_source()
async def send_outbound(action, *, platform=None):
return {
"success": True,
"message_id": "m1",
"thread_id": "th-9",
"auto_thread_name": "Initial words",
}
stub_conn.send_outbound = send_outbound # type: ignore[method-assign]
async def deliver_late():
await asyncio.sleep(0.05)
await adapter.send("chan-parent", "the reply")
task = asyncio.create_task(deliver_late())
await runner._rename_discord_auto_thread_for_session_title(
src, "sess1", "Debugging the flux capacitor"
)
await task
# Relay lane uses the connector-owned guard (prefer_connector_created=True),
# not the fragile cross-repo initial-name string. It MUST pass the PARENT
# channel chat_id so the connector's egress guard can resolve the tenant
# (the discriminator caches are keyed by the parent channel, not the thread;
# omitting it made the connector decline "target not routed to an onboarded
# tenant" — the live failure on staging 2026-08-01).
assert renames == [
("th-9", "Debugging the flux capacitor", True, "chan-parent")
]
@pytest.mark.asyncio
async def test_title_rename_true_miss_noops():
"""The connector didn't auto-thread this reply, so there is nothing to rename."""
import asyncio
adapter, stub_conn = _adapter()
renames: list = []
async def rename_thread(thread_id, name, **kw):
renames.append(thread_id)
return True
adapter.rename_thread = rename_thread # type: ignore[method-assign]
runner = _mk_runner_stub()(adapter)
src = _relay_channel_source()
async def send_outbound(action, *, platform=None):
return {"success": True, "message_id": "m1"}
stub_conn.send_outbound = send_outbound # type: ignore[method-assign]
async def deliver():
await asyncio.sleep(0.05)
await adapter.send("chan-parent", "the reply")
task = asyncio.create_task(deliver())
await runner._rename_discord_auto_thread_for_session_title(
src, "sess1", "A title"
)
await task
assert renames == []