fix(slack): scope top-level channel messages by channel-only when reply_in_thread=false (#15421)
Top-level Slack channel messages previously fell back to the message's
own ``ts`` as a synthetic ``thread_ts``:
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
That value flows into ``build_source(thread_id=thread_ts)`` at
line 1247. The gateway session store keys sessions by
``(platform, channel_id, thread_id)``, so every top-level channel
message ended up on a unique session. Operators who set
``reply_in_thread: false`` in ``config.yaml`` expected all top-level
channel messages to share one session (the whole point of that flag)
— instead each one spawned a fresh conversation with no context
carry-over.
### Fix
Three explicit cases in the channel branch:
| event.thread_ts | reply_in_thread | thread_ts for session keying |
|---|---|---|
| non-null (real thread reply) | either | event.thread_ts |
| null (top-level) | true (default) | ts (legacy: own-thread sessions) |
| null (top-level) | false | **None** (shared channel session) |
The outbound-reply gate at line 1264 (``reply_to_message_id =
thread_ts if thread_ts != ts else None``) still works correctly in
all three cases without further changes: ``None != ts`` is True, so
shared-channel top-level messages don't get their reply threaded
either — matching the operator's ``reply_in_thread=false`` intent
end-to-end.
Genuine thread replies still scope per-thread under both modes so
multi-person threaded conversations can't collide with unrelated
channel chatter.
### Tests (7 new in ``tests/gateway/test_slack_channel_session_scope.py``)
All drive the real ``SlackAdapter._handle_slack_message`` code path
(not a re-implementation) via the standard pytest fixture pattern
used by ``tests/gateway/test_slack.py``. Messages @mention the bot
so the mention gate doesn't drop them — the tests are specifically
about what happens once the handler decides to emit a ``MessageEvent``.
* ``TestChannelSessionScopeDefault`` (2 cases):
- Explicit ``reply_in_thread: true`` keeps ``thread_id = ts``
(legacy behaviour — regression guard)
- Unset config behaves like ``reply_in_thread: true`` (pins the
default)
* ``TestChannelSessionScopeShared`` (3 cases):
- ``reply_in_thread: false`` + top-level → ``thread_id is None``
(the #15421 bug 1 fix)
- ``reply_to_message_id is None`` in the same case (no threaded
outbound reply)
- Genuine thread reply still scopes per-thread when shared mode is
on — only TOP-LEVEL messages collapse to the channel session
* ``TestThreadReplyAlwaysScopesByThread`` (2 parametrised cases):
- Thread replies get ``thread_id = event.thread_ts`` regardless of
``reply_in_thread`` — critical invariant for multi-thread
channels; a regression here would leak per-thread context across
threads
**Regression guard verified**: reverted the else-branch to the legacy
``thread_ts = event.get("thread_ts") or ts`` one-liner;
``test_top_level_maps_to_none_when_reply_in_thread_false`` correctly
failed (asserts ``thread_id is None`` but got ``"1700000000.000003"``).
Restored → 182 slack tests pass (175 existing + 7 new).
Scope: this fixes #15421 bug 1 only. Bug 2 (sessions.json not
persisting across compression) lives elsewhere in the session
manager and is left for a separate diff.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Teknium
co-authored by
Claude Opus 4.7
parent
b5a457c033
commit
133e0271e2
@@ -2291,7 +2291,32 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not thread_ts and self._dm_top_level_threads_as_sessions():
|
||||
thread_ts = ts
|
||||
else:
|
||||
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
|
||||
# Channel message session scoping.
|
||||
#
|
||||
# Three cases:
|
||||
# (a) genuine thread reply → scope session per thread
|
||||
# (b) top-level, reply_in_thread=true (the default) →
|
||||
# legacy behaviour: each top-level message becomes its
|
||||
# own thread, so the UX still "replies in a thread"
|
||||
# and sessions are keyed per thread root
|
||||
# (c) top-level, reply_in_thread=false → scope one session
|
||||
# across the whole channel so context accumulates across
|
||||
# messages (#15421 bug 1)
|
||||
event_thread_ts_raw = event.get("thread_ts")
|
||||
if event_thread_ts_raw:
|
||||
thread_ts = event_thread_ts_raw
|
||||
elif self.config.extra.get("reply_in_thread", True):
|
||||
# Legacy default: treat ts as a synthetic thread root so
|
||||
# this top-level message gets its own session.
|
||||
thread_ts = ts
|
||||
else:
|
||||
# reply_in_thread=false: no thread key → session manager
|
||||
# groups by (platform, channel_id, None) and the channel
|
||||
# shares one conversation. reply_to_message_id at the
|
||||
# outbound side is already gated on ``thread_ts != ts``
|
||||
# so None here produces a non-threaded reply without
|
||||
# further changes.
|
||||
thread_ts = None
|
||||
|
||||
# In channels, respond if:
|
||||
# 0. Channel is in free_response_channels, OR require_mention is
|
||||
|
||||
Reference in New Issue
Block a user