fix(slack): scope top-level channel messages by channel-only when reply_in_thread=false (#15421) - #15464
Conversation
…ly_in_thread=false (NousResearch#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 NousResearch#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 NousResearch#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>
There was a problem hiding this comment.
Pull request overview
Fixes Slack channel session scoping so that when reply_in_thread: false, top-level channel messages share a single channel-scoped session (instead of each message creating an isolated session keyed by its own ts).
Changes:
- Update
SlackAdapter._handle_slack_messageto setthread_id=Nonefor top-level channel messages whenreply_in_thread=false, while preserving per-thread scoping for real thread replies. - Add targeted regression tests covering default vs shared channel session behavior and thread-reply invariants.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
gateway/platforms/slack.py |
Adjusts channel thread_ts derivation to enable shared channel sessions when reply_in_thread=false. |
tests/gateway/test_slack_channel_session_scope.py |
Adds regression tests validating session keying (thread_id) and reply threading behavior across modes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 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: |
There was a problem hiding this comment.
Channel scoping currently treats any truthy event.get("thread_ts") as a “genuine thread reply”, but later in this method is_thread_reply is defined as event_thread_ts and event_thread_ts != ts. If Slack ever includes thread_ts == ts on a thread root (or in some payload variants), this branch would incorrectly keep thread_ts=ts even when reply_in_thread=false, defeating the shared-channel session scoping. Consider aligning the condition here with the same thread_ts != ts invariant used below (i.e., only treat it as a real thread when event_thread_ts_raw is present and differs from ts).
| if event_thread_ts_raw: | |
| if event_thread_ts_raw and event_thread_ts_raw != ts: |
| """The outbound-side ``reply_to_message_id`` check already | ||
| uses ``thread_ts != ts`` to decide whether to thread the | ||
| response. When ``thread_ts`` is None, the check evaluates | ||
| ``None != ts`` → True → reply_to_message_id IS set. That would | ||
| thread the reply, which is the opposite of what | ||
| reply_in_thread=false means for top-level messages. | ||
|
|
||
| The fix ensures reply_to_message_id is None for top-level | ||
| messages in shared-session mode so the bot posts a fresh | ||
| channel message (not a threaded reply). |
There was a problem hiding this comment.
This docstring claims that when thread_ts is None, the thread_ts != ts check results in reply_to_message_id being set and the reply being threaded. In the production code (reply_to_message_id = thread_ts if thread_ts != ts else None), None != ts is True so the expression evaluates to thread_ts (i.e., None), meaning the reply remains unthreaded. Please update the docstring to reflect the actual behavior/invariant being tested.
| """The outbound-side ``reply_to_message_id`` check already | |
| uses ``thread_ts != ts`` to decide whether to thread the | |
| response. When ``thread_ts`` is None, the check evaluates | |
| ``None != ts`` → True → reply_to_message_id IS set. That would | |
| thread the reply, which is the opposite of what | |
| reply_in_thread=false means for top-level messages. | |
| The fix ensures reply_to_message_id is None for top-level | |
| messages in shared-session mode so the bot posts a fresh | |
| channel message (not a threaded reply). | |
| """In shared-session mode, top-level channel messages are | |
| normalized to ``thread_ts = None``. With the production logic | |
| ``reply_to_message_id = thread_ts if thread_ts != ts else None``, | |
| that still yields ``reply_to_message_id = None`` for top-level | |
| messages, so the bot posts an unthreaded channel message. | |
| This regression test locks in that invariant for | |
| ``reply_in_thread=false``. |
…ilot NousResearch#15464) Two findings from Copilot's review on NousResearch#15464, both addressed: 1. ``event.get("thread_ts")`` truthy vs ``event_thread_ts != ts``: the new channel branch treated ANY truthy ``thread_ts`` as a real thread reply, but three lines below ``is_thread_reply`` is defined with the stricter ``event_thread_ts and event_thread_ts != ts`` invariant. If Slack ever ships a payload where ``thread_ts == ts`` on a thread root, the stricter check would treat it as a top-level message for the ``is_thread_reply`` path but as a thread reply for session keying — divergent behaviour. Aligned this branch to the same ``and event_thread_ts_raw != ts`` invariant. 2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring had the ternary logic backwards ("None != ts → reply_to_message_id IS set"). The code reads ``reply_to_message_id = thread_ts if thread_ts != ts else None`` — with ``thread_ts = None``, the condition is True so the expression evaluates to ``thread_ts`` itself (None), meaning the reply stays un-threaded. The test asserted the correct end-state; only the explanatory docstring was wrong. Rewrote the docstring to match the actual code flow, with the note that Copilot caught the reversal. 7/7 tests still pass. No behaviour change for the existing test_thread_reply_scopes_by_thread_even_when_shared case because ``event_thread_ts_raw = "1700000000.000000"`` and ``ts = "1700000000.000005"`` are distinct — the new ``!= ts`` guard is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks @copilot — both findings are real. Pushed 1. You're right that my branch treated any truthy 2. Docstring logic reversed ✅ Caught me. The docstring claimed "None != ts → reply_to_message_id IS set" but the production code reads 7/7 tests still pass. No behaviour change for Meta: these two findings are exactly the class of review I was hoping to reduce with the pre-push checklist I've been building. (1) needed a "check for semantic invariants defined elsewhere in the same function and align with them" rule I hadn't articulated — adding it now. (2) was a docstring-vs-code mismatch, which IS on my checklist but I missed because I was focused on the assertion being correct rather than tracing the explanatory text. Good signal for where the discipline is still weak. |
|
Closing — superseded by 4b5a88d71 on That's the OUT-side fix; mine was an IN-side fix in @alt-glitch had also flagged the parent issue #15421 as a duplicate of #9268, and the reporter accepted that verdict. Closing here so the queue isn't carrying redundant work. |
…ilot #15464) Two findings from Copilot's review on #15464, both addressed: 1. ``event.get("thread_ts")`` truthy vs ``event_thread_ts != ts``: the new channel branch treated ANY truthy ``thread_ts`` as a real thread reply, but three lines below ``is_thread_reply`` is defined with the stricter ``event_thread_ts and event_thread_ts != ts`` invariant. If Slack ever ships a payload where ``thread_ts == ts`` on a thread root, the stricter check would treat it as a top-level message for the ``is_thread_reply`` path but as a thread reply for session keying — divergent behaviour. Aligned this branch to the same ``and event_thread_ts_raw != ts`` invariant. 2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring had the ternary logic backwards ("None != ts → reply_to_message_id IS set"). The code reads ``reply_to_message_id = thread_ts if thread_ts != ts else None`` — with ``thread_ts = None``, the condition is True so the expression evaluates to ``thread_ts`` itself (None), meaning the reply stays un-threaded. The test asserted the correct end-state; only the explanatory docstring was wrong. Rewrote the docstring to match the actual code flow, with the note that Copilot caught the reversal. 7/7 tests still pass. No behaviour change for the existing test_thread_reply_scopes_by_thread_even_when_shared case because ``event_thread_ts_raw = "1700000000.000000"`` and ``ts = "1700000000.000005"`` are distinct — the new ``!= ts`` guard is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Merged via #41703 — your two commits were cherry-picked onto current Salvage notes:
Thanks for the fix and the thorough test coverage. |
…ilot NousResearch#15464) Two findings from Copilot's review on NousResearch#15464, both addressed: 1. ``event.get("thread_ts")`` truthy vs ``event_thread_ts != ts``: the new channel branch treated ANY truthy ``thread_ts`` as a real thread reply, but three lines below ``is_thread_reply`` is defined with the stricter ``event_thread_ts and event_thread_ts != ts`` invariant. If Slack ever ships a payload where ``thread_ts == ts`` on a thread root, the stricter check would treat it as a top-level message for the ``is_thread_reply`` path but as a thread reply for session keying — divergent behaviour. Aligned this branch to the same ``and event_thread_ts_raw != ts`` invariant. 2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring had the ternary logic backwards ("None != ts → reply_to_message_id IS set"). The code reads ``reply_to_message_id = thread_ts if thread_ts != ts else None`` — with ``thread_ts = None``, the condition is True so the expression evaluates to ``thread_ts`` itself (None), meaning the reply stays un-threaded. The test asserted the correct end-state; only the explanatory docstring was wrong. Rewrote the docstring to match the actual code flow, with the note that Copilot caught the reversal. 7/7 tests still pass. No behaviour change for the existing test_thread_reply_scopes_by_thread_even_when_shared case because ``event_thread_ts_raw = "1700000000.000000"`` and ``ts = "1700000000.000005"`` are distinct — the new ``!= ts`` guard is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ilot #15464) Two findings from Copilot's review on #15464, both addressed: 1. ``event.get("thread_ts")`` truthy vs ``event_thread_ts != ts``: the new channel branch treated ANY truthy ``thread_ts`` as a real thread reply, but three lines below ``is_thread_reply`` is defined with the stricter ``event_thread_ts and event_thread_ts != ts`` invariant. If Slack ever ships a payload where ``thread_ts == ts`` on a thread root, the stricter check would treat it as a top-level message for the ``is_thread_reply`` path but as a thread reply for session keying — divergent behaviour. Aligned this branch to the same ``and event_thread_ts_raw != ts`` invariant. 2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring had the ternary logic backwards ("None != ts → reply_to_message_id IS set"). The code reads ``reply_to_message_id = thread_ts if thread_ts != ts else None`` — with ``thread_ts = None``, the condition is True so the expression evaluates to ``thread_ts`` itself (None), meaning the reply stays un-threaded. The test asserted the correct end-state; only the explanatory docstring was wrong. Rewrote the docstring to match the actual code flow, with the note that Copilot caught the reversal. 7/7 tests still pass. No behaviour change for the existing test_thread_reply_scopes_by_thread_even_when_shared case because ``event_thread_ts_raw = "1700000000.000000"`` and ``ts = "1700000000.000005"`` are distinct — the new ``!= ts`` guard is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ilot NousResearch#15464) Two findings from Copilot's review on NousResearch#15464, both addressed: 1. ``event.get("thread_ts")`` truthy vs ``event_thread_ts != ts``: the new channel branch treated ANY truthy ``thread_ts`` as a real thread reply, but three lines below ``is_thread_reply`` is defined with the stricter ``event_thread_ts and event_thread_ts != ts`` invariant. If Slack ever ships a payload where ``thread_ts == ts`` on a thread root, the stricter check would treat it as a top-level message for the ``is_thread_reply`` path but as a thread reply for session keying — divergent behaviour. Aligned this branch to the same ``and event_thread_ts_raw != ts`` invariant. 2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring had the ternary logic backwards ("None != ts → reply_to_message_id IS set"). The code reads ``reply_to_message_id = thread_ts if thread_ts != ts else None`` — with ``thread_ts = None``, the condition is True so the expression evaluates to ``thread_ts`` itself (None), meaning the reply stays un-threaded. The test asserted the correct end-state; only the explanatory docstring was wrong. Rewrote the docstring to match the actual code flow, with the note that Copilot caught the reversal. 7/7 tests still pass. No behaviour change for the existing test_thread_reply_scopes_by_thread_even_when_shared case because ``event_thread_ts_raw = "1700000000.000000"`` and ``ts = "1700000000.000005"`` are distinct — the new ``!= ts`` guard is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ilot NousResearch#15464) Two findings from Copilot's review on NousResearch#15464, both addressed: 1. ``event.get("thread_ts")`` truthy vs ``event_thread_ts != ts``: the new channel branch treated ANY truthy ``thread_ts`` as a real thread reply, but three lines below ``is_thread_reply`` is defined with the stricter ``event_thread_ts and event_thread_ts != ts`` invariant. If Slack ever ships a payload where ``thread_ts == ts`` on a thread root, the stricter check would treat it as a top-level message for the ``is_thread_reply`` path but as a thread reply for session keying — divergent behaviour. Aligned this branch to the same ``and event_thread_ts_raw != ts`` invariant. 2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring had the ternary logic backwards ("None != ts → reply_to_message_id IS set"). The code reads ``reply_to_message_id = thread_ts if thread_ts != ts else None`` — with ``thread_ts = None``, the condition is True so the expression evaluates to ``thread_ts`` itself (None), meaning the reply stays un-threaded. The test asserted the correct end-state; only the explanatory docstring was wrong. Rewrote the docstring to match the actual code flow, with the note that Copilot caught the reversal. 7/7 tests still pass. No behaviour change for the existing test_thread_reply_scopes_by_thread_even_when_shared case because ``event_thread_ts_raw = "1700000000.000000"`` and ``ts = "1700000000.000005"`` are distinct — the new ``!= ts`` guard is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ilot NousResearch#15464) Two findings from Copilot's review on NousResearch#15464, both addressed: 1. ``event.get("thread_ts")`` truthy vs ``event_thread_ts != ts``: the new channel branch treated ANY truthy ``thread_ts`` as a real thread reply, but three lines below ``is_thread_reply`` is defined with the stricter ``event_thread_ts and event_thread_ts != ts`` invariant. If Slack ever ships a payload where ``thread_ts == ts`` on a thread root, the stricter check would treat it as a top-level message for the ``is_thread_reply`` path but as a thread reply for session keying — divergent behaviour. Aligned this branch to the same ``and event_thread_ts_raw != ts`` invariant. 2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring had the ternary logic backwards ("None != ts → reply_to_message_id IS set"). The code reads ``reply_to_message_id = thread_ts if thread_ts != ts else None`` — with ``thread_ts = None``, the condition is True so the expression evaluates to ``thread_ts`` itself (None), meaning the reply stays un-threaded. The test asserted the correct end-state; only the explanatory docstring was wrong. Rewrote the docstring to match the actual code flow, with the note that Copilot caught the reversal. 7/7 tests still pass. No behaviour change for the existing test_thread_reply_scopes_by_thread_even_when_shared case because ``event_thread_ts_raw = "1700000000.000000"`` and ``ts = "1700000000.000005"`` are distinct — the new ``!= ts`` guard is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ilot NousResearch#15464) Two findings from Copilot's review on NousResearch#15464, both addressed: 1. ``event.get("thread_ts")`` truthy vs ``event_thread_ts != ts``: the new channel branch treated ANY truthy ``thread_ts`` as a real thread reply, but three lines below ``is_thread_reply`` is defined with the stricter ``event_thread_ts and event_thread_ts != ts`` invariant. If Slack ever ships a payload where ``thread_ts == ts`` on a thread root, the stricter check would treat it as a top-level message for the ``is_thread_reply`` path but as a thread reply for session keying — divergent behaviour. Aligned this branch to the same ``and event_thread_ts_raw != ts`` invariant. 2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring had the ternary logic backwards ("None != ts → reply_to_message_id IS set"). The code reads ``reply_to_message_id = thread_ts if thread_ts != ts else None`` — with ``thread_ts = None``, the condition is True so the expression evaluates to ``thread_ts`` itself (None), meaning the reply stays un-threaded. The test asserted the correct end-state; only the explanatory docstring was wrong. Rewrote the docstring to match the actual code flow, with the note that Copilot caught the reversal. 7/7 tests still pass. No behaviour change for the existing test_thread_reply_scopes_by_thread_even_when_shared case because ``event_thread_ts_raw = "1700000000.000000"`` and ``ts = "1700000000.000005"`` are distinct — the new ``!= ts`` guard is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ilot NousResearch#15464) Two findings from Copilot's review on NousResearch#15464, both addressed: 1. ``event.get("thread_ts")`` truthy vs ``event_thread_ts != ts``: the new channel branch treated ANY truthy ``thread_ts`` as a real thread reply, but three lines below ``is_thread_reply`` is defined with the stricter ``event_thread_ts and event_thread_ts != ts`` invariant. If Slack ever ships a payload where ``thread_ts == ts`` on a thread root, the stricter check would treat it as a top-level message for the ``is_thread_reply`` path but as a thread reply for session keying — divergent behaviour. Aligned this branch to the same ``and event_thread_ts_raw != ts`` invariant. 2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring had the ternary logic backwards ("None != ts → reply_to_message_id IS set"). The code reads ``reply_to_message_id = thread_ts if thread_ts != ts else None`` — with ``thread_ts = None``, the condition is True so the expression evaluates to ``thread_ts`` itself (None), meaning the reply stays un-threaded. The test asserted the correct end-state; only the explanatory docstring was wrong. Rewrote the docstring to match the actual code flow, with the note that Copilot caught the reversal. 7/7 tests still pass. No behaviour change for the existing test_thread_reply_scopes_by_thread_even_when_shared case because ``event_thread_ts_raw = "1700000000.000000"`` and ``ts = "1700000000.000005"`` are distinct — the new ``!= ts`` guard is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ilot NousResearch#15464) Two findings from Copilot's review on NousResearch#15464, both addressed: 1. ``event.get("thread_ts")`` truthy vs ``event_thread_ts != ts``: the new channel branch treated ANY truthy ``thread_ts`` as a real thread reply, but three lines below ``is_thread_reply`` is defined with the stricter ``event_thread_ts and event_thread_ts != ts`` invariant. If Slack ever ships a payload where ``thread_ts == ts`` on a thread root, the stricter check would treat it as a top-level message for the ``is_thread_reply`` path but as a thread reply for session keying — divergent behaviour. Aligned this branch to the same ``and event_thread_ts_raw != ts`` invariant. 2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring had the ternary logic backwards ("None != ts → reply_to_message_id IS set"). The code reads ``reply_to_message_id = thread_ts if thread_ts != ts else None`` — with ``thread_ts = None``, the condition is True so the expression evaluates to ``thread_ts`` itself (None), meaning the reply stays un-threaded. The test asserted the correct end-state; only the explanatory docstring was wrong. Rewrote the docstring to match the actual code flow, with the note that Copilot caught the reversal. 7/7 tests still pass. No behaviour change for the existing test_thread_reply_scopes_by_thread_even_when_shared case because ``event_thread_ts_raw = "1700000000.000000"`` and ``ts = "1700000000.000005"`` are distinct — the new ``!= ts`` guard is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…es + corpora Completes the four-platform ingress mirror (telegram/whatsapp landed in the prior commit). Layer model (documented in discord_parse.py): layer 1 (payload→SDK object — discord.py/Bolt) is the SDK-equivalence axiom, not oracled; layer 2 (SDK-view → MessageEvent fields — the Hermes-unique rules) is THE extracted, vectored spec; layer 3 (effects) stays adapter-side. - plugins/platforms/discord/discord_parse.py: SDK-view IR (DiscordMessageView, dual access: discord.py objects OR raw-vocabulary dicts) + pure rules — chat-type classification, <@id>/<@!id> mention stripping (strip THEN command-detect), forwarded-snapshot folding, referenced-attachment inheritance, attachment→type classification (voice-note vs audio via is_voice_message/duration+waveform), guild/forum thread naming. Adapter delegates: _is_discord_voice_message_ attachment, _format_thread_chat_name, and the _handle_message classification block. - plugins/platforms/slack/slack_parse.py: DM/MPIM classification (1:1 vs shared-surface MPIM), thread_ts session scoping (#15421/#15464 invariants incl. the thread_ts==ts root shape), mention detection, bot-message classification. Adapter delegates the DM-classification and channel-scoping blocks. - scripts/generate_ingress_vectors.py: +15 discord + 12 slack vectors (54 total across four platforms). - tests/conformance/test_ingress_vectors.py: +3 oracle-fidelity tests (adapter shim ≡ core on SDK-like objects) + scar-rule coverage; 18 total. Suites: conformance 18; discord/slack gateway suites green (the only failures in the -k 'discord or slack' sweep are pre-existing order-dependent pollution — reproduce identically with this diff stashed).
…ilot NousResearch#15464) Two findings from Copilot's review on NousResearch#15464, both addressed: 1. ``event.get("thread_ts")`` truthy vs ``event_thread_ts != ts``: the new channel branch treated ANY truthy ``thread_ts`` as a real thread reply, but three lines below ``is_thread_reply`` is defined with the stricter ``event_thread_ts and event_thread_ts != ts`` invariant. If Slack ever ships a payload where ``thread_ts == ts`` on a thread root, the stricter check would treat it as a top-level message for the ``is_thread_reply`` path but as a thread reply for session keying — divergent behaviour. Aligned this branch to the same ``and event_thread_ts_raw != ts`` invariant. 2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring had the ternary logic backwards ("None != ts → reply_to_message_id IS set"). The code reads ``reply_to_message_id = thread_ts if thread_ts != ts else None`` — with ``thread_ts = None``, the condition is True so the expression evaluates to ``thread_ts`` itself (None), meaning the reply stays un-threaded. The test asserted the correct end-state; only the explanatory docstring was wrong. Rewrote the docstring to match the actual code flow, with the note that Copilot caught the reversal. 7/7 tests still pass. No behaviour change for the existing test_thread_reply_scopes_by_thread_even_when_shared case because ``event_thread_ts_raw = "1700000000.000000"`` and ``ts = "1700000000.000005"`` are distinct — the new ``!= ts`` guard is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
What does this PR do?
Fixes `#15421` bug 1: top-level Slack channel messages previously fell back to the message's own `ts` as a synthetic `thread_ts`:
```python
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
```
That value flowed into `build_source(thread_id=thread_ts)`. 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 — instead each one spawned a fresh conversation with no context carry-over.
Fix
Three explicit cases in the channel branch:
The outbound-reply gate at line 1264 (`reply_to_message_id = thread_ts if thread_ts != ts else None`) already 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.
Related Issue
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.
Type of Change
Test plan
Test coverage detail
All tests 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 — regression guard for the legacy default):
`TestChannelSessionScopeShared` (3 cases — the #15421 fix):
`TestThreadReplyAlwaysScopesByThread` (2 parametrised cases):
Not in scope