diff --git a/gateway/run.py b/gateway/run.py index 911021a36ee..4870a187cfc 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5338,8 +5338,17 @@ class TurnRunner: title, ) elif self._runner._is_discord_auto_thread_lane(ctx.source) or ( - self._runner._relay_auto_thread_info(ctx.source) is not None + self._runner._is_relay_discord_channel_lane(ctx.source) ): + # Relay note: the second predicate is shape-only (relay + # Discord channel event). Whether the connector actually + # auto-threaded our reply is only knowable AFTER delivery + # (send-result feedback), which on the non-streaming lane + # happens after this registration runs — so the callback + # must be registered eagerly and the rename lane performs + # the cache lookup at fire time (staging repro 2026-07-31: + # gating registration on the cache read meant it never + # registered and no thread_rename op was ever sent). maybe_auto_title_kwargs["title_callback"] = lambda title: self._runner._schedule_discord_semantic_thread_rename( ctx.source, effective_session_id, @@ -18813,6 +18822,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew and bool(getattr(source, "auto_thread_initial_name", None)) ) + def _is_relay_discord_channel_lane(self, source: SessionSource) -> bool: + """Shape-only check: a relay-delivered Discord CHANNEL event whose + reply the connector MAY auto-thread (title-turn registration gate). + + Deliberately does NOT consult the send-result cache: at registration + time (before delivery) the feedback can't exist yet. The rename lane + polls the cache at fire time instead.""" + return ( + source.platform == Platform.DISCORD + and bool(source.chat_id) + and not source.thread_id + and source.chat_type in ("group", "channel") + and getattr(source, "delivered_via_upstream_relay", False) is True + ) + def _relay_auto_thread_info( self, source: SessionSource ) -> Optional[Tuple[str, str]]: @@ -18882,7 +18906,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if relay_info is None and not await asyncio.to_thread( self._is_discord_auto_thread_lane, source ): - return + # Relay title turn with no feedback captured at schedule time: + # the auto-title thread races the delivery that produces the + # connector's send-result feedback (thread_id + initial name). + # Poll the adapter cache briefly before giving up — delivery is + # typically milliseconds-to-seconds behind the title. + if not self._is_relay_discord_channel_lane(source): + return + for _ in range(20): # up to ~10s + relay_info = self._relay_auto_thread_info(source) + if relay_info is not None: + break + await asyncio.sleep(0.5) + if relay_info is None: + # True miss: the connector did not auto-thread this reply + # (policy off, DM, already-threaded, or send failed). + return adapter = self._adapter_for_source(source) if getattr(self, "adapters", None) else None if adapter is None: return @@ -18918,9 +18957,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if not self._is_discord_auto_thread_lane(source): # Relay title turn: the source is the PARENT channel event (the # thread didn't exist at ingest, so no auto-thread markers). The - # connector's send-result feedback tells us where the reply landed. + # connector's send-result feedback tells us where the reply + # landed — but the auto-title thread races the delivery that + # produces it, so a cache miss HERE is not a verdict. Schedule + # whenever the SHAPE matches; the async rename lane polls the + # cache (with a bounded wait) and no-ops on a true miss. relay_info = self._relay_auto_thread_info(source) - if relay_info is None: + if relay_info is None and not self._is_relay_discord_channel_lane( + source + ): return try: loop = asyncio.get_running_loop() diff --git a/tests/gateway/relay/test_relay_threads.py b/tests/gateway/relay/test_relay_threads.py index e511fffdc94..a4180108a1f 100644 --- a/tests/gateway/relay/test_relay_threads.py +++ b/tests/gateway/relay/test_relay_threads.py @@ -23,7 +23,7 @@ from typing import Any, Dict import pytest -from gateway.config import PlatformConfig +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 @@ -212,3 +212,128 @@ async def test_auto_thread_feedback_is_bounded(): 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 + _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_title_rename_polls_feedback_that_arrives_late(): + """The auto-title races delivery: feedback lands AFTER the rename lane + starts. The lane must poll the adapter cache and still rename.""" + import asyncio + + adapter, stub_conn = _adapter() + renames: list = [] + + async def rename_thread(thread_id, name, *, only_if_current_name=None, parent_chat_id=None): + renames.append((thread_id, name, only_if_current_name)) + return True + + adapter.rename_thread = rename_thread # type: ignore[method-assign] + runner = _mk_runner_stub()(adapter) + src = _relay_channel_source() + + async def land_feedback_late(): + await asyncio.sleep(0.7) # past the first poll tick + adapter._auto_thread_by_chat["chan-parent"] = ("th-9", "Initial words") + + task = asyncio.create_task(land_feedback_late()) + await runner._rename_discord_auto_thread_for_session_title( + src, "sess1", "Debugging the flux capacitor" + ) + await task + assert renames == [("th-9", "Debugging the flux capacitor", "Initial words")] + + +@pytest.mark.asyncio +async def test_title_rename_true_miss_noops(monkeypatch): + """No feedback ever arrives (connector didn't auto-thread): no rename.""" + import gateway.run as run_mod + + adapter, _ = _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() + # Shrink the poll loop for test speed: 20 ticks of 0.5s -> patch sleep. + orig_sleep = run_mod.asyncio.sleep + + async def fast_sleep(_s): + await orig_sleep(0) + + monkeypatch.setattr(run_mod.asyncio, "sleep", fast_sleep) + await runner._rename_discord_auto_thread_for_session_title( + src, "sess1", "A title" + ) + assert renames == []