Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions gateway/relay/ws_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 36 additions & 4 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"),
)


Expand Down Expand Up @@ -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:
Expand Down
84 changes: 84 additions & 0 deletions tests/gateway/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading