fix(relay): key auto-thread rename on prospective_thread_id, not per-chat cache (#77052)
The Discord semantic thread-rename lane resolved the target thread from `_relay_auto_thread_info`, which read a single-slot-per-parent-chat cache (`adapter._auto_thread_by_chat[chat_id]`, populated from connector SendResult feedback). When two auto-threads spawned from the SAME parent channel, the second send overwrote the first's slot and the title turn's read raced the write — so only the FIRST thread in a channel ever got its semantic rename. Staging repro 2026-08-02: message A's thread renamed to "A Hundred Word Sword Story", sibling message B's thread stayed stuck at the raw first-words name. The connector now stamps `prospective_thread_id` on the inbound (the anchor message id, which is the id of the thread it will auto-create) — shipped for per-thread session keying. Reuse it here: it is deterministic and per-message, so it names the EXACT thread even when several auto-threads share one channel. `_relay_auto_thread_info` returns it directly (with an empty initial-name marker) and never consults the collision-prone per-chat cache; the connector's own created-name guard (`prefer_connector_created`) still enforces no-clobber, so no initial name is needed gateway-side. The send-result cache path stays as a fallback for older connectors that don't stamp the field. Tests: two new cases in test_relay_threads.py — prospective id wins over a poisoned cache entry, and two sibling threads in one channel each rename to their own thread id. Full gateway session + relay suites green (211 passed).
This commit is contained in:
parent
c83ddd6a51
commit
d0b87dad77
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue