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(