From 34845ba67b1d6778f5a7512fc0944f11786f8f14 Mon Sep 17 00:00:00 2001 From: Doruk Ardahan Date: Sun, 12 Apr 2026 23:57:32 +0300 Subject: [PATCH 1/4] fix(slack): respect reply_in_thread and reply_to_mode for channel replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When reply_in_thread is false or reply_to_mode is "off", top-level channel messages should receive direct channel replies instead of thread replies. Two runtime bugs prevented this: 1. _resolve_thread_ts returned the message's own ts for top-level messages because the gateway sets thread_id on all channel messages for session keying. The fix distinguishes top-level messages (where reply_to == thread_id, both equal the message ts) from genuine in-thread replies (where they differ). 2. send_typing called assistant_threads_setStatus on top-level messages, which activates a Slack assistant thread and forces subsequent replies into that thread — even when _resolve_thread_ts correctly returns None. The fix uses an emoji reaction (hourglass_flowing_sand) as a lightweight processing indicator instead, cleaned up after the response is sent. Also adds reply_to_mode=="off" as an alternative trigger (in addition to reply_in_thread=false) for consistency with the platform config schema. Fixes #7532 (runtime logic — complementary to config-bridging PRs) Fixes #8387 (prevention — complementary to cleanup PRs) --- gateway/platforms/slack.py | 28 +++++++++++++-- tests/gateway/test_slack.py | 71 +++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 8f9934cf7a2db..31707892f26ce 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -355,6 +355,18 @@ async def send_typing(self, chat_id: str, metadata=None) -> None: if not thread_ts: return # Can only set status in a thread context + # When reply_in_thread is disabled or reply_to_mode is off, + # skip assistant thread status for top-level messages. The + # setStatus API activates an assistant thread on the target + # message, which would force subsequent replies into a thread + # even when _resolve_thread_ts correctly returns None. Use an + # emoji reaction as a lightweight processing indicator instead. + if not self.config.extra.get("reply_in_thread", True) or self.config.reply_to_mode == "off": + msg_ts = (metadata or {}).get("message_id") or thread_ts + if msg_ts: + await self._add_reaction(chat_id, msg_ts, "hourglass_flowing_sand") + return + try: await self._get_client(chat_id).assistant_threads_setStatus( channel_id=chat_id, @@ -381,10 +393,15 @@ def _resolve_thread_ts( thread replies. Messages that originate inside an existing thread are always replied to in-thread to preserve conversation context. """ - # When reply_in_thread is disabled (default: True for backward compat), - # only thread messages that are already part of an existing thread. - if not self.config.extra.get("reply_in_thread", True): + # When reply_in_thread is disabled (default: True for backward compat) + # or reply_to_mode is "off", only reply in-thread for messages that + # are genuinely inside an existing thread. Top-level channel messages + # have thread_id == reply_to (both equal the message's own ts, set for + # session keying) — return None so the reply goes to the channel. + if not self.config.extra.get("reply_in_thread", True) or self.config.reply_to_mode == "off": existing_thread = (metadata or {}).get("thread_id") or (metadata or {}).get("thread_ts") + if not reply_to or reply_to == existing_thread: + return None return existing_thread or None if metadata: @@ -1192,6 +1209,11 @@ async def _handle_slack_message(self, event: dict) -> None: await self._remove_reaction(channel_id, ts, "eyes") await self._add_reaction(channel_id, ts, "white_check_mark") + # Clean up hourglass reaction added by send_typing() for + # non-thread replies (only when the reaction path was active). + if not self.config.extra.get("reply_in_thread", True) or self.config.reply_to_mode == "off": + await self._remove_reaction(channel_id, ts, "hourglass_flowing_sand") + # ----- Approval button support (Block Kit) ----- async def send_exec_approval( diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index bf99bba9fe062..3510905b1bccc 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -590,6 +590,77 @@ async def test_uses_thread_ts_fallback(self, adapter): status="is thinking...", ) + @pytest.mark.asyncio + async def test_uses_reaction_when_reply_in_thread_false(self, adapter): + """When reply_in_thread=false, send_typing adds a reaction instead of setStatus.""" + adapter.config.extra["reply_in_thread"] = False + adapter._app.client.assistant_threads_setStatus = AsyncMock() + adapter._app.client.reactions_add = AsyncMock() + await adapter.send_typing("C123", metadata={"thread_id": "ts1", "message_id": "ts1"}) + adapter._app.client.assistant_threads_setStatus.assert_not_called() + adapter._app.client.reactions_add.assert_called_once_with( + channel="C123", timestamp="ts1", name="hourglass_flowing_sand", + ) + + @pytest.mark.asyncio + async def test_uses_reaction_when_reply_to_mode_off(self, adapter): + """When reply_to_mode=off, send_typing adds a reaction instead of setStatus.""" + adapter.config.reply_to_mode = "off" + adapter._app.client.assistant_threads_setStatus = AsyncMock() + adapter._app.client.reactions_add = AsyncMock() + await adapter.send_typing("C123", metadata={"thread_id": "ts1", "message_id": "ts1"}) + adapter._app.client.assistant_threads_setStatus.assert_not_called() + adapter._app.client.reactions_add.assert_called_once() + + +# --------------------------------------------------------------------------- +# TestResolveThreadTs — thread routing logic +# --------------------------------------------------------------------------- + + +class TestResolveThreadTs: + """Test _resolve_thread_ts correctly distinguishes top-level and in-thread.""" + + def test_default_returns_thread_id(self, adapter): + """Default config: returns thread_id from metadata.""" + result = adapter._resolve_thread_ts("reply_ts", {"thread_id": "parent_ts"}) + assert result == "parent_ts" + + def test_default_returns_reply_to_as_fallback(self, adapter): + """Default config: falls back to reply_to when no metadata.""" + result = adapter._resolve_thread_ts("reply_ts") + assert result == "reply_ts" + + def test_no_thread_for_top_level_when_disabled(self, adapter): + """reply_in_thread=false: top-level messages get None (channel reply).""" + adapter.config.extra["reply_in_thread"] = False + result = adapter._resolve_thread_ts("ts123", {"thread_id": "ts123"}) + assert result is None + + def test_thread_for_genuine_reply_when_disabled(self, adapter): + """reply_in_thread=false: genuine thread replies still get thread_ts.""" + adapter.config.extra["reply_in_thread"] = False + result = adapter._resolve_thread_ts("child_ts", {"thread_id": "parent_ts"}) + assert result == "parent_ts" + + def test_no_thread_for_top_level_when_reply_to_mode_off(self, adapter): + """reply_to_mode=off: top-level messages get None.""" + adapter.config.reply_to_mode = "off" + result = adapter._resolve_thread_ts("ts123", {"thread_id": "ts123"}) + assert result is None + + def test_none_reply_to_returns_none_when_disabled(self, adapter): + """reply_in_thread=false: proactive messages (no reply_to) get None.""" + adapter.config.extra["reply_in_thread"] = False + result = adapter._resolve_thread_ts(None, {"thread_id": "ts123"}) + assert result is None + + def test_no_metadata_returns_none_when_disabled(self, adapter): + """reply_in_thread=false: no metadata returns None.""" + adapter.config.extra["reply_in_thread"] = False + result = adapter._resolve_thread_ts("ts123") + assert result is None + # --------------------------------------------------------------------------- # TestFormatMessage — Markdown → mrkdwn conversion From d2e2b0870023ada0d3532b5eea9f6cf5d65f243e Mon Sep 17 00:00:00 2001 From: Doruk Ardahan Date: Mon, 13 Apr 2026 00:07:54 +0300 Subject: [PATCH 2/4] fix(slack): preserve in-thread delivery for internal sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Codex review finding: when reply_to is None but metadata has a real thread_id (internal sends like typing updates, TTS, media), the previous check `not reply_to` incorrectly returned None, causing these messages to go to the channel instead of the thread. Fix: use `reply_to == existing_thread` instead, which correctly handles all four cases: - Top-level: reply_to == thread_id (same ts) → None (channel) - Proactive: reply_to == thread_id (both None) → None (channel) - Internal send: reply_to=None, thread_id=parent → thread reply - Genuine thread: reply_to=child, thread_id=parent → thread reply Add test coverage for the internal send case. --- gateway/platforms/slack.py | 7 ++++++- tests/gateway/test_slack.py | 20 ++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 31707892f26ce..dd24f598d5bd6 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -400,7 +400,12 @@ def _resolve_thread_ts( # session keying) — return None so the reply goes to the channel. if not self.config.extra.get("reply_in_thread", True) or self.config.reply_to_mode == "off": existing_thread = (metadata or {}).get("thread_id") or (metadata or {}).get("thread_ts") - if not reply_to or reply_to == existing_thread: + # Top-level channel messages have reply_to == existing_thread + # (both equal the message's own ts, set for session keying). + # Proactive messages have reply_to == existing_thread == None. + # Internal sends (typing, TTS, media) have reply_to=None but + # existing_thread set to a real parent — those must stay in-thread. + if reply_to == existing_thread: return None return existing_thread or None diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 3510905b1bccc..ddd125ecfa190 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -649,14 +649,26 @@ def test_no_thread_for_top_level_when_reply_to_mode_off(self, adapter): result = adapter._resolve_thread_ts("ts123", {"thread_id": "ts123"}) assert result is None - def test_none_reply_to_returns_none_when_disabled(self, adapter): - """reply_in_thread=false: proactive messages (no reply_to) get None.""" + def test_internal_send_stays_in_thread_when_disabled(self, adapter): + """reply_in_thread=false: internal sends (reply_to=None, real thread) stay in-thread.""" adapter.config.extra["reply_in_thread"] = False - result = adapter._resolve_thread_ts(None, {"thread_id": "ts123"}) + result = adapter._resolve_thread_ts(None, {"thread_id": "parent_ts"}) + assert result == "parent_ts" + + def test_proactive_message_returns_none_when_disabled(self, adapter): + """reply_in_thread=false: proactive messages (both None) get None.""" + adapter.config.extra["reply_in_thread"] = False + result = adapter._resolve_thread_ts(None, {}) + assert result is None + + def test_proactive_no_metadata_returns_none_when_disabled(self, adapter): + """reply_in_thread=false: no metadata at all returns None.""" + adapter.config.extra["reply_in_thread"] = False + result = adapter._resolve_thread_ts(None) assert result is None def test_no_metadata_returns_none_when_disabled(self, adapter): - """reply_in_thread=false: no metadata returns None.""" + """reply_in_thread=false: reply_to only, no metadata returns None.""" adapter.config.extra["reply_in_thread"] = False result = adapter._resolve_thread_ts("ts123") assert result is None From 86c82a99c3575139bc2230e31004c5ec9f75acca Mon Sep 17 00:00:00 2001 From: Doruk Ardahan Date: Mon, 13 Apr 2026 01:56:43 +0300 Subject: [PATCH 3/4] fix(slack): respect reply_in_thread and reply_to_mode for channel replies When reply_in_thread is false or reply_to_mode is "off", the bot replies in threads instead of directly in the channel. Root cause: the gateway sets thread_id on ALL channel messages for session keying, and the base class propagates it via _progress_metadata to every send call. _resolve_thread_ts cannot distinguish top-level from in-thread because reply_to is always None and thread_id is always set. Fix: set a per-message _force_channel_reply flag in _handle_slack_message where is_thread_reply context is available. _resolve_thread_ts and send_typing check this flag to suppress threading and assistant status for top-level messages while preserving in-thread delivery for genuine thread replies. Three changes in gateway/platforms/slack.py: 1. _handle_slack_message: set _force_channel_reply flag 2. _resolve_thread_ts: return None when flag is set 3. send_typing: skip setStatus when flag is set (prevents assistant_threads_setStatus from creating a thread) Fixes #7532, Fixes #8387 --- gateway/platforms/slack.py | 48 ++++++++++++------------- tests/gateway/test_slack.py | 72 ++++++++++--------------------------- 2 files changed, 41 insertions(+), 79 deletions(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index dd24f598d5bd6..d88f1d20f879a 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -355,16 +355,11 @@ async def send_typing(self, chat_id: str, metadata=None) -> None: if not thread_ts: return # Can only set status in a thread context - # When reply_in_thread is disabled or reply_to_mode is off, - # skip assistant thread status for top-level messages. The - # setStatus API activates an assistant thread on the target - # message, which would force subsequent replies into a thread - # even when _resolve_thread_ts correctly returns None. Use an - # emoji reaction as a lightweight processing indicator instead. - if not self.config.extra.get("reply_in_thread", True) or self.config.reply_to_mode == "off": - msg_ts = (metadata or {}).get("message_id") or thread_ts - if msg_ts: - await self._add_reaction(chat_id, msg_ts, "hourglass_flowing_sand") + # Skip assistant thread status for top-level channel messages when + # reply_in_thread is disabled. The setStatus API activates an + # assistant thread, which would force replies into a thread even + # when _resolve_thread_ts returns None. + if getattr(self, "_force_channel_reply", False): return try: @@ -394,19 +389,13 @@ def _resolve_thread_ts( always replied to in-thread to preserve conversation context. """ # When reply_in_thread is disabled (default: True for backward compat) - # or reply_to_mode is "off", only reply in-thread for messages that - # are genuinely inside an existing thread. Top-level channel messages - # have thread_id == reply_to (both equal the message's own ts, set for - # session keying) — return None so the reply goes to the channel. + # or reply_to_mode is "off", suppress threading for top-level messages. + # _force_channel_reply is set per-message by _handle_slack_message + # (True for top-level, False for genuine thread replies). if not self.config.extra.get("reply_in_thread", True) or self.config.reply_to_mode == "off": - existing_thread = (metadata or {}).get("thread_id") or (metadata or {}).get("thread_ts") - # Top-level channel messages have reply_to == existing_thread - # (both equal the message's own ts, set for session keying). - # Proactive messages have reply_to == existing_thread == None. - # Internal sends (typing, TTS, media) have reply_to=None but - # existing_thread set to a real parent — those must stay in-thread. - if reply_to == existing_thread: + if getattr(self, "_force_channel_reply", False): return None + existing_thread = (metadata or {}).get("thread_id") or (metadata or {}).get("thread_ts") return existing_thread or None if metadata: @@ -1179,6 +1168,18 @@ async def _handle_slack_message(self, event: dict) -> None: # Resolve user display name (cached after first lookup) user_name = await self._resolve_user_name(user_id, chat_id=channel_id) + # When reply_in_thread is disabled or reply_to_mode is off, mark + # top-level messages so _resolve_thread_ts and send_typing suppress + # threading. The flag is per-message, reset on each inbound event. + # Safe in single-threaded asyncio. + self._force_channel_reply = ( + not is_thread_reply + and ( + not self.config.extra.get("reply_in_thread", True) + or self.config.reply_to_mode == "off" + ) + ) + # Build source source = self.build_source( chat_id=channel_id, @@ -1214,11 +1215,6 @@ async def _handle_slack_message(self, event: dict) -> None: await self._remove_reaction(channel_id, ts, "eyes") await self._add_reaction(channel_id, ts, "white_check_mark") - # Clean up hourglass reaction added by send_typing() for - # non-thread replies (only when the reaction path was active). - if not self.config.extra.get("reply_in_thread", True) or self.config.reply_to_mode == "off": - await self._remove_reaction(channel_id, ts, "hourglass_flowing_sand") - # ----- Approval button support (Block Kit) ----- async def send_exec_approval( diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index ddd125ecfa190..d054055777f24 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -591,26 +591,12 @@ async def test_uses_thread_ts_fallback(self, adapter): ) @pytest.mark.asyncio - async def test_uses_reaction_when_reply_in_thread_false(self, adapter): - """When reply_in_thread=false, send_typing adds a reaction instead of setStatus.""" - adapter.config.extra["reply_in_thread"] = False + async def test_skips_status_when_force_channel_reply(self, adapter): + """When _force_channel_reply is set, send_typing skips setStatus.""" + adapter._force_channel_reply = True adapter._app.client.assistant_threads_setStatus = AsyncMock() - adapter._app.client.reactions_add = AsyncMock() - await adapter.send_typing("C123", metadata={"thread_id": "ts1", "message_id": "ts1"}) - adapter._app.client.assistant_threads_setStatus.assert_not_called() - adapter._app.client.reactions_add.assert_called_once_with( - channel="C123", timestamp="ts1", name="hourglass_flowing_sand", - ) - - @pytest.mark.asyncio - async def test_uses_reaction_when_reply_to_mode_off(self, adapter): - """When reply_to_mode=off, send_typing adds a reaction instead of setStatus.""" - adapter.config.reply_to_mode = "off" - adapter._app.client.assistant_threads_setStatus = AsyncMock() - adapter._app.client.reactions_add = AsyncMock() - await adapter.send_typing("C123", metadata={"thread_id": "ts1", "message_id": "ts1"}) + await adapter.send_typing("C123", metadata={"thread_id": "ts1"}) adapter._app.client.assistant_threads_setStatus.assert_not_called() - adapter._app.client.reactions_add.assert_called_once() # --------------------------------------------------------------------------- @@ -619,60 +605,40 @@ async def test_uses_reaction_when_reply_to_mode_off(self, adapter): class TestResolveThreadTs: - """Test _resolve_thread_ts correctly distinguishes top-level and in-thread.""" + """Test _resolve_thread_ts with _force_channel_reply flag.""" def test_default_returns_thread_id(self, adapter): """Default config: returns thread_id from metadata.""" result = adapter._resolve_thread_ts("reply_ts", {"thread_id": "parent_ts"}) assert result == "parent_ts" - def test_default_returns_reply_to_as_fallback(self, adapter): - """Default config: falls back to reply_to when no metadata.""" - result = adapter._resolve_thread_ts("reply_ts") - assert result == "reply_ts" - - def test_no_thread_for_top_level_when_disabled(self, adapter): - """reply_in_thread=false: top-level messages get None (channel reply).""" + def test_force_channel_reply_returns_none(self, adapter): + """Top-level with reply_in_thread=false: returns None via flag.""" adapter.config.extra["reply_in_thread"] = False - result = adapter._resolve_thread_ts("ts123", {"thread_id": "ts123"}) + adapter._force_channel_reply = True + result = adapter._resolve_thread_ts(None, {"thread_id": "ts123"}) assert result is None - def test_thread_for_genuine_reply_when_disabled(self, adapter): - """reply_in_thread=false: genuine thread replies still get thread_ts.""" + def test_thread_reply_stays_in_thread_when_disabled(self, adapter): + """Genuine thread reply with reply_in_thread=false: stays in thread.""" adapter.config.extra["reply_in_thread"] = False - result = adapter._resolve_thread_ts("child_ts", {"thread_id": "parent_ts"}) + adapter._force_channel_reply = False + result = adapter._resolve_thread_ts(None, {"thread_id": "parent_ts"}) assert result == "parent_ts" - def test_no_thread_for_top_level_when_reply_to_mode_off(self, adapter): - """reply_to_mode=off: top-level messages get None.""" + def test_reply_to_mode_off_with_flag(self, adapter): + """reply_to_mode=off + _force_channel_reply: returns None.""" adapter.config.reply_to_mode = "off" - result = adapter._resolve_thread_ts("ts123", {"thread_id": "ts123"}) + adapter._force_channel_reply = True + result = adapter._resolve_thread_ts(None, {"thread_id": "ts123"}) assert result is None - def test_internal_send_stays_in_thread_when_disabled(self, adapter): - """reply_in_thread=false: internal sends (reply_to=None, real thread) stay in-thread.""" + def test_no_flag_preserves_thread(self, adapter): + """Without _force_channel_reply: thread_id preserved (backward compat).""" adapter.config.extra["reply_in_thread"] = False result = adapter._resolve_thread_ts(None, {"thread_id": "parent_ts"}) assert result == "parent_ts" - def test_proactive_message_returns_none_when_disabled(self, adapter): - """reply_in_thread=false: proactive messages (both None) get None.""" - adapter.config.extra["reply_in_thread"] = False - result = adapter._resolve_thread_ts(None, {}) - assert result is None - - def test_proactive_no_metadata_returns_none_when_disabled(self, adapter): - """reply_in_thread=false: no metadata at all returns None.""" - adapter.config.extra["reply_in_thread"] = False - result = adapter._resolve_thread_ts(None) - assert result is None - - def test_no_metadata_returns_none_when_disabled(self, adapter): - """reply_in_thread=false: reply_to only, no metadata returns None.""" - adapter.config.extra["reply_in_thread"] = False - result = adapter._resolve_thread_ts("ts123") - assert result is None - # --------------------------------------------------------------------------- # TestFormatMessage — Markdown → mrkdwn conversion From 72df7dab44fc02b553ac06e23cf8d75740e38a71 Mon Sep 17 00:00:00 2001 From: Doruk Ardahan Date: Mon, 13 Apr 2026 02:09:43 +0300 Subject: [PATCH 4/4] fix(slack): use contextvars for async-safe per-task routing state Address 3-model consensus review finding: _force_channel_reply as an instance variable creates a race condition when Bolt dispatches concurrent events as separate asyncio tasks. Replace with a contextvars.ContextVar which is isolated per-task. Also use defensive getattr for config.reply_to_mode to avoid AttributeError on older config objects. Add backward compatibility test for default config path. --- gateway/platforms/slack.py | 20 +++++++++++++++----- tests/gateway/test_slack.py | 20 ++++++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index d88f1d20f879a..5737a2cb740b1 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -9,6 +9,7 @@ """ import asyncio +import contextvars import json import logging import os @@ -17,6 +18,15 @@ from dataclasses import dataclass, field from typing import Dict, Optional, Any, Tuple +# Per-task flag: True when the current inbound message is a top-level +# channel message that should NOT create a thread reply. Set by +# _handle_slack_message, read by _resolve_thread_ts and send_typing. +# Using ContextVar (not an instance variable) so concurrent asyncio +# tasks each get their own value without cross-contamination. +_force_channel_reply: contextvars.ContextVar[bool] = contextvars.ContextVar( + "_force_channel_reply", default=False +) + try: from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler @@ -359,7 +369,7 @@ async def send_typing(self, chat_id: str, metadata=None) -> None: # reply_in_thread is disabled. The setStatus API activates an # assistant thread, which would force replies into a thread even # when _resolve_thread_ts returns None. - if getattr(self, "_force_channel_reply", False): + if _force_channel_reply.get(): return try: @@ -392,8 +402,8 @@ def _resolve_thread_ts( # or reply_to_mode is "off", suppress threading for top-level messages. # _force_channel_reply is set per-message by _handle_slack_message # (True for top-level, False for genuine thread replies). - if not self.config.extra.get("reply_in_thread", True) or self.config.reply_to_mode == "off": - if getattr(self, "_force_channel_reply", False): + if not self.config.extra.get("reply_in_thread", True) or getattr(self.config, "reply_to_mode", None) == "off": + if _force_channel_reply.get(): return None existing_thread = (metadata or {}).get("thread_id") or (metadata or {}).get("thread_ts") return existing_thread or None @@ -1172,11 +1182,11 @@ async def _handle_slack_message(self, event: dict) -> None: # top-level messages so _resolve_thread_ts and send_typing suppress # threading. The flag is per-message, reset on each inbound event. # Safe in single-threaded asyncio. - self._force_channel_reply = ( + _force_channel_reply.set( not is_thread_reply and ( not self.config.extra.get("reply_in_thread", True) - or self.config.reply_to_mode == "off" + or getattr(self.config, "reply_to_mode", None) == "off" ) ) diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index d054055777f24..d900bffc6ba1f 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -59,7 +59,7 @@ def _ensure_slack_mock(): import gateway.platforms.slack as _slack_mod _slack_mod.SLACK_AVAILABLE = True -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from gateway.platforms.slack import SlackAdapter, _force_channel_reply # noqa: E402 # --------------------------------------------------------------------------- @@ -593,7 +593,7 @@ async def test_uses_thread_ts_fallback(self, adapter): @pytest.mark.asyncio async def test_skips_status_when_force_channel_reply(self, adapter): """When _force_channel_reply is set, send_typing skips setStatus.""" - adapter._force_channel_reply = True + _force_channel_reply.set(True) adapter._app.client.assistant_threads_setStatus = AsyncMock() await adapter.send_typing("C123", metadata={"thread_id": "ts1"}) adapter._app.client.assistant_threads_setStatus.assert_not_called() @@ -615,30 +615,38 @@ def test_default_returns_thread_id(self, adapter): def test_force_channel_reply_returns_none(self, adapter): """Top-level with reply_in_thread=false: returns None via flag.""" adapter.config.extra["reply_in_thread"] = False - adapter._force_channel_reply = True + _force_channel_reply.set(True) result = adapter._resolve_thread_ts(None, {"thread_id": "ts123"}) assert result is None def test_thread_reply_stays_in_thread_when_disabled(self, adapter): """Genuine thread reply with reply_in_thread=false: stays in thread.""" adapter.config.extra["reply_in_thread"] = False - adapter._force_channel_reply = False + _force_channel_reply.set(False) result = adapter._resolve_thread_ts(None, {"thread_id": "parent_ts"}) assert result == "parent_ts" def test_reply_to_mode_off_with_flag(self, adapter): """reply_to_mode=off + _force_channel_reply: returns None.""" adapter.config.reply_to_mode = "off" - adapter._force_channel_reply = True + _force_channel_reply.set(True) result = adapter._resolve_thread_ts(None, {"thread_id": "ts123"}) assert result is None def test_no_flag_preserves_thread(self, adapter): - """Without _force_channel_reply: thread_id preserved (backward compat).""" + """Without _force_channel_reply: thread_id preserved (genuine thread).""" adapter.config.extra["reply_in_thread"] = False + _force_channel_reply.set(False) result = adapter._resolve_thread_ts(None, {"thread_id": "parent_ts"}) assert result == "parent_ts" + def test_default_config_fully_backward_compatible(self, adapter): + """Default config (reply_in_thread=True): entire block skipped, thread preserved.""" + # Default adapter has reply_in_thread not set (defaults True) + # ContextVar defaults False — should not matter, block is skipped + result = adapter._resolve_thread_ts("reply_ts", {"thread_id": "parent_ts"}) + assert result == "parent_ts" + # --------------------------------------------------------------------------- # TestFormatMessage — Markdown → mrkdwn conversion