From af93cb33cb56c5426a539a812affb346b89fb79d Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Sat, 1 Aug 2026 18:34:01 -0700 Subject: [PATCH] feat(gateway): key Discord auto-thread sessions on prospective_thread_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gateway/relay/ws_transport.py | 6 +++ gateway/session.py | 40 +++++++++++++++-- tests/gateway/test_session.py | 84 +++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index f19b39405c00..a4bfc4f6b260 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -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 diff --git a/gateway/session.py b/gateway/session.py index cdddace0751a..ff086fe5badd 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -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 @@ def to_dict(self) -> Dict[str, Any]: 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 @@ def from_dict(cls, data: Dict[str, Any]) -> "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: diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 4e598aa53f4b..5b48ddd22da5 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -742,6 +742,90 @@ def test_group_thread_sessions_are_shared_by_default(self): 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(