diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 534ffea914f37..d64e2fb00b8a7 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -2060,6 +2060,7 @@ class RelayAdapter(BasePlatformAdapter): name: str, *, only_if_current_name: Optional[str] = None, + prefer_connector_created: bool = False, parent_chat_id: Optional[str] = None, ) -> bool: """Best-effort thread rename via the connector's `thread_rename` op. @@ -2068,10 +2069,15 @@ class RelayAdapter(BasePlatformAdapter): called by the SAME semantic-rename lane (run.py _rename_discord_auto_thread_for_session_title), which fires only for sources carrying the connector-stamped auto-thread markers. - ``only_if_current_name`` crosses the wire; the CONNECTOR enforces the - no-clobber guard (it owns the platform read), failing safe on - platforms that can't read the current name. ``parent_chat_id`` is - the containing chat where the caller knows it (Telegram needs it); + + No-clobber guard: prefer ``prefer_connector_created=True``, which asks + the CONNECTOR to enforce the guard from ITS OWN created-name memory + (only_if_connector_created) — the gateway no longer has to reproduce + the thread's initial name byte-for-byte, which drifted on any + normalization difference and silently declined every relay rename. + ``only_if_current_name`` is the legacy string guard, kept for the + native-marker lane and older connectors. ``parent_chat_id`` is the + containing chat where the caller knows it (Telegram needs it); defaults to the thread id itself (Discord ignores chat_id). """ if self._transport is None or not self.descriptor.supports_op("thread_rename"): @@ -2087,7 +2093,9 @@ class RelayAdapter(BasePlatformAdapter): "thread_name": cleaned[:100], "metadata": self._with_scope(chat_id, None), } - if only_if_current_name is not None: + if prefer_connector_created: + action["only_if_connector_created"] = True + elif only_if_current_name is not None: action["only_if_current_name"] = str(only_if_current_name) try: result = await self._transport.send_outbound( diff --git a/gateway/run.py b/gateway/run.py index 43853f9b66b80..c28f695530987 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -18962,18 +18962,36 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if rename_thread is None: return target_thread_id = relay_info[0] if relay_info else str(source.thread_id) + # Relay lane (relay_info present): ask the CONNECTOR to enforce the + # no-clobber guard from its own created-name memory — the gateway + # can't reliably reproduce the thread's initial name byte-for-byte + # (normalization drift silently declined every rename before this). + # Native-marker lane keeps the legacy string guard. + use_connector_guard = relay_info is not None guard_name = ( - relay_info[1] - if relay_info + None + if use_connector_guard else getattr(source, "auto_thread_initial_name", None) ) thread_name = self._sanitize_discord_thread_title(title) + logger.info( + "discord auto-thread rename: thread=%s lane=%s new_title=%r", + target_thread_id, + "relay" if use_connector_guard else "native", + thread_name, + ) try: - await rename_thread( + renamed = await rename_thread( target_thread_id, thread_name, + prefer_connector_created=use_connector_guard, only_if_current_name=guard_name, ) + logger.info( + "discord auto-thread rename result: thread=%s applied=%s", + target_thread_id, + bool(renamed), + ) except Exception: logger.debug("Failed to rename Discord auto-thread for generated session title", exc_info=True) diff --git a/tests/gateway/relay/test_relay_threads.py b/tests/gateway/relay/test_relay_threads.py index a4180108a1f06..c4aae3e495f9b 100644 --- a/tests/gateway/relay/test_relay_threads.py +++ b/tests/gateway/relay/test_relay_threads.py @@ -118,6 +118,38 @@ async def test_rename_thread_parent_chat_and_gating(): 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 + + # ── the relay semantic-rename lane (marker parity) ─────────────────────── @@ -291,8 +323,15 @@ async def test_title_rename_polls_feedback_that_arrives_late(): 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)) + 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)) return True adapter.rename_thread = rename_thread # type: ignore[method-assign] @@ -308,7 +347,9 @@ async def test_title_rename_polls_feedback_that_arrives_late(): src, "sess1", "Debugging the flux capacitor" ) await task - assert renames == [("th-9", "Debugging the flux capacitor", "Initial words")] + # Relay lane uses the connector-owned guard (prefer_connector_created=True), + # not the fragile cross-repo initial-name string. + assert renames == [("th-9", "Debugging the flux capacitor", True)] @pytest.mark.asyncio