diff --git a/gateway/run.py b/gateway/run.py index fdf2b82d41f24..6480755876f20 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19079,14 +19079,31 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew not exist at ingest, so no markers can be present and the native lane check never matches on the relay title turn (staging repro 2026-07-29: initial titles fine, semantic renames never happened). - The connector reports where the reply actually landed on the send - result (contract §SendResult thread_id/auto_thread_name); the relay - adapter caches it per chat and this reads it back. + + Preferred path: the connector stamps ``prospective_thread_id`` on the + inbound (the anchor message id, which IS the id of the thread it will + auto-create). It's deterministic and per-message, so it identifies the + EXACT thread even when several auto-threads spawn from one channel — + unlike the send-result cache below, which held a single slot per parent + chat and so only the FIRST thread in a channel ever renamed (staging + repro 2026-08-02: thread A renamed, sibling thread B stuck at raw + text). The connector's own created-name guard (prefer_connector_created) + enforces no-clobber, so no initial name is needed here. + + Fallback: the connector reports where the reply actually landed on the + send result (contract §SendResult thread_id/auto_thread_name); the + relay adapter caches it per chat and this reads it back — kept for + older connectors that don't stamp prospective_thread_id. """ if source.platform != Platform.DISCORD or not source.chat_id: return None if not getattr(source, "delivered_via_upstream_relay", False): return None + prospective = getattr(source, "prospective_thread_id", None) + if prospective: + # Deterministic per-thread identity; the empty initial-name marker + # signals the caller to rely on the connector-side no-clobber guard. + return (str(prospective), "") adapter = self._adapter_for_source(source) info_fn = getattr(adapter, "auto_thread_info_for_chat", None) if not callable(info_fn): diff --git a/tests/gateway/relay/test_relay_threads.py b/tests/gateway/relay/test_relay_threads.py index a0f5fe1522464..bb1dd45d14c2c 100644 --- a/tests/gateway/relay/test_relay_threads.py +++ b/tests/gateway/relay/test_relay_threads.py @@ -348,6 +348,68 @@ def test_relay_channel_lane_shape_gate(): ) +@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_polls_feedback_that_arrives_late(): """The auto-title races delivery: feedback lands AFTER the rename lane