feat(gateway): key Discord auto-thread sessions on prospective_thread_id (#76513)

Live staging (2026-08-02): only the FIRST auto-thread in a channel got an
auto-title/rename. Root cause is a grouping-model mismatch — the connector
auto-threads per message (each channel message spawns its own thread), but the
gateway keyed sessions per PARENT CHANNEL, so every message after the first
reused the first message's already-titled session; auto-title short-circuited
and the rename lane never fired for later threads.

Intended model: a channel message INITIATES a session, the thread CONTINUES it.
A Discord thread created from a message reuses that message's id as the thread
id, so the connector can tell us the thread id at inbound (before the thread
exists). The paired connector change stamps it as source.prospective_thread_id;
this keys the session on it:

- SessionSource.prospective_thread_id (new field; to_dict/from_dict + the relay
  ws_transport inbound source build read it off the wire).
- build_session_key: effective_thread_id = thread_id or prospective_thread_id.
  The channel-initiating message (no thread_id, carries prospective) and the
  later follow-ups that arrive IN that thread (real thread_id ==
  prospective_thread_id) now produce the SAME key. A real thread_id always
  wins. The chat_type slot is normalized to "thread" when keying on a
  prospective id so the initiating "group"/"channel" event byte-matches the
  follow-up "thread" event. Prospective-thread sessions are shared across
  participants like any thread (not per-user).

Net effect: each distinct channel message is its own session/thread and gets
its own title + rename; follow-ups inside a thread continue that session with
full history. Additive and inert until the connector sends the field, so
non-relay and pre-deploy behaviour is byte-identical.

Tests: initiate-then-continue share one session; distinct channel messages get
distinct sessions; real thread_id wins over prospective; prospective sessions
shared across participants. Session suite 59 passed; relay suite green; ruff +
footguns clean.

Paired: gateway-gateway stamps prospective_thread_id per auto-threading instance.
This commit is contained in:
Ben Barclay 2026-08-01 18:42:53 -07:00 committed by GitHub
parent eca996aa33
commit a041526efe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 126 additions and 4 deletions

View File

@ -219,6 +219,12 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
# (_is_discord_auto_thread_lane's relay-aware sibling reads these).
auto_thread_created=bool(src.get("auto_thread_created", False)),
auto_thread_initial_name=src.get("auto_thread_initial_name"),
# Discord auto-thread session continuity: the connector stamps the
# thread id this channel message's reply WILL be auto-threaded into
# (== the message id) so the gateway keys the initiating channel message
# and its later in-thread follow-ups to ONE session. See
# build_session_key / SessionSource.prospective_thread_id.
prospective_thread_id=src.get("prospective_thread_id"),
# Authentic upstream-trust signal: this event arrived over the
# per-instance-authenticated relay WS, so the connector already resolved
# it to this instance's owner-bound author. ``platform`` is the

View File

@ -191,6 +191,18 @@ class SessionSource:
auto_thread_created: bool = False
auto_thread_initial_name: Optional[str] = None
# Discord auto-thread session-continuity signal. Set by the connector on an
# inbound CHANNEL message (no thread_id yet) that its auto-thread policy WILL
# deliver into a newly-created thread. A Discord thread created from a message
# reuses that message's id as the thread id, so the connector knows the id
# before the thread exists. The gateway keys the session on this so a
# channel message and its thread follow-ups share ONE session: the channel
# message INITIATES it (keyed on the prospective thread id), and later
# messages arriving in that thread (real thread_id == this value) CONTINUE
# it. Without this, every channel message collapses into one parent-channel
# session and only the first auto-thread ever gets an auto-title/rename.
prospective_thread_id: Optional[str] = None
# Internal, wire-INVISIBLE trust signal: True when this event was delivered
# to the gateway over the per-instance-authenticated relay WebSocket (the
# Team Gateway connector). The connector authenticates the gateway's socket
@ -268,6 +280,8 @@ class SessionSource:
d["auto_thread_created"] = True
if self.auto_thread_initial_name:
d["auto_thread_initial_name"] = self.auto_thread_initial_name
if self.prospective_thread_id:
d["prospective_thread_id"] = self.prospective_thread_id
return d
@classmethod
@ -291,6 +305,7 @@ class SessionSource:
profile=data.get("profile"),
auto_thread_created=bool(data.get("auto_thread_created", False)),
auto_thread_initial_name=data.get("auto_thread_initial_name"),
prospective_thread_id=data.get("prospective_thread_id"),
)
@ -1111,20 +1126,37 @@ def build_session_key(
# single group member gets two isolated per-user sessions when the
# bridge reshuffles alias forms.
participant_id = canonical_whatsapp_identifier(str(participant_id)) or participant_id
key_parts = [ns, platform, source.chat_type]
# Discord auto-thread continuity: a channel-initiating message carries no
# thread_id yet, but the connector tells us the thread its reply WILL be
# auto-threaded into (prospective_thread_id == the message id, which becomes
# the thread id). Key the session on that so the initiating channel message
# and every follow-up that later arrives IN that thread (real thread_id ==
# prospective_thread_id) resolve to the SAME session — "initiate in channel,
# continue in thread". A real thread_id always wins when present.
#
# The follow-up arrives with chat_type="thread" while the initiating message
# has chat_type="group"/"channel"; normalize the chat_type slot to "thread"
# when keying on a prospective id so the two byte-match. (Real-thread events
# already carry chat_type="thread", so this only rewrites the initiating
# channel message's slot.)
effective_thread_id = source.thread_id or source.prospective_thread_id
chat_type_slot = source.chat_type
if source.prospective_thread_id and not source.thread_id:
chat_type_slot = "thread"
key_parts = [ns, platform, chat_type_slot]
if slack_scope_id:
key_parts.append(slack_scope_id)
if source.chat_id:
key_parts.append(source.chat_id)
if source.thread_id:
key_parts.append(source.thread_id)
if effective_thread_id:
key_parts.append(effective_thread_id)
# In threads, default to shared sessions (all participants see the same
# conversation). Per-user isolation only applies when explicitly enabled
# via thread_sessions_per_user, or when there is no thread (regular group).
isolate_user = group_sessions_per_user
if source.thread_id and not thread_sessions_per_user:
if effective_thread_id and not thread_sessions_per_user:
isolate_user = False
if isolate_user and participant_id:

View File

@ -742,6 +742,90 @@ class TestWhatsAppSessionKeyConsistency:
assert build_session_key(alice) == build_session_key(bob)
def test_discord_prospective_thread_initiates_and_continues_one_session(self):
"""Discord auto-thread continuity: a channel-initiating message (no
thread_id, but a connector-supplied prospective_thread_id) and the later
follow-ups that arrive IN that thread (real thread_id == the prospective
id) must resolve to ONE session "initiate in channel, continue in
thread". This is the fix for every-thread-after-the-first never getting
an auto-title/rename (staging 2026-08-02)."""
# The channel-initiating message: no thread yet, connector says it will
# be threaded into thread id "msg-100" (== the message id).
initiating = SessionSource(
platform=Platform.DISCORD,
chat_id="channel-1",
chat_type="group",
user_id="cthulhu",
prospective_thread_id="msg-100",
)
# A follow-up that actually arrives inside that thread.
follow_up = SessionSource(
platform=Platform.DISCORD,
chat_id="channel-1",
chat_type="thread",
thread_id="msg-100",
user_id="cthulhu",
)
key_init = build_session_key(initiating)
key_follow = build_session_key(follow_up)
assert key_init.endswith(":msg-100")
assert key_init == key_follow
def test_discord_distinct_prospective_threads_are_distinct_sessions(self):
"""Two different channel messages each initiate their OWN thread/session,
so each gets its own auto-title/rename (the reported bug: only the first
thread per channel was ever named)."""
first = SessionSource(
platform=Platform.DISCORD,
chat_id="channel-1",
chat_type="group",
user_id="cthulhu",
prospective_thread_id="msg-100",
)
second = SessionSource(
platform=Platform.DISCORD,
chat_id="channel-1",
chat_type="group",
user_id="cthulhu",
prospective_thread_id="msg-200",
)
assert build_session_key(first) != build_session_key(second)
assert build_session_key(first).endswith(":msg-100")
assert build_session_key(second).endswith(":msg-200")
def test_real_thread_id_wins_over_prospective(self):
"""A real thread_id always takes precedence over prospective_thread_id
(they normally match; if both are somehow set, the real one wins)."""
source = SessionSource(
platform=Platform.DISCORD,
chat_id="channel-1",
chat_type="thread",
thread_id="real-thread",
prospective_thread_id="ignored",
user_id="cthulhu",
)
assert build_session_key(source).endswith(":real-thread")
def test_prospective_thread_shares_across_participants(self):
"""A prospective-thread session is shared across participants, same as a
real thread (thread sessions are not per-user by default)."""
alice = SessionSource(
platform=Platform.DISCORD,
chat_id="channel-1",
chat_type="group",
user_id="alice",
prospective_thread_id="msg-100",
)
bob = SessionSource(
platform=Platform.DISCORD,
chat_id="channel-1",
chat_type="group",
user_id="bob",
prospective_thread_id="msg-100",
)
assert build_session_key(alice) == build_session_key(bob)
def test_non_thread_group_sessions_still_isolated_per_user(self):
"""Regular group messages (no thread_id) remain per-user by default."""
alice = SessionSource(