From fe201dd56ce25e7040eb6774f1468e46e1741de7 Mon Sep 17 00:00:00 2001 From: Greg Duraj Date: Wed, 22 Jul 2026 08:13:21 -0700 Subject: [PATCH] fix(slack): avoid rich-text duplication in commands + keep slash thread identity Slack rich_text blocks mirror the original message text. When bang commands are rewritten from !model to /model, appending block text makes the command arguments include a duplicate payload, so the model switcher sees spaces in the model name and rejects valid commands like: !model qwen3.7-plus --provider opencode-go Skip block extraction for command messages while preserving it for normal messages. Also preserve Slack thread_ts (top-level or nested in message/container payload shapes) on native slash-command payloads so session-scoped commands like /model apply to the intended thread instead of a channel+user key the next threaded message never matches. Surgical reapply of PR #43533 (originally against gateway/platforms/slack.py, now plugins/platforms/slack/adapter.py). Thread-shape widening credit also to #66310. --- plugins/platforms/slack/adapter.py | 36 ++++++++++++++++++++- tests/gateway/test_slack.py | 51 ++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index c91c3cc61d31..89bff29a6906 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -3817,8 +3817,16 @@ class SlackAdapter(BasePlatformAdapter): # array as ``rich_text_quote`` elements, which are NOT reflected in # the plain ``text`` field. Merge block text so the agent sees the # full message content. + # + # Skip blocks extraction for command messages (slash/bang commands). + # Slack's rich_text blocks mirror the plain text of the message; after + # a ``!cmd`` → ``/cmd`` rewrite the mirrored ``!cmd`` form is no longer + # a substring of the rewritten text, so a naive dedupe check would + # re-append the same visible message as bogus command arguments + # (e.g. ``/model qwen --provider X`` grows a duplicate line and the + # model name appears to contain spaces). blocks = event.get("blocks") - if blocks: + if blocks and not is_command_text: blocks_text = _extract_text_from_slack_blocks(blocks) if blocks_text: # Only append if the blocks contain text not already present @@ -5754,11 +5762,37 @@ class SlackAdapter(BasePlatformAdapter): # Preserve DM semantics only for DM channel IDs; shared channels must # keep group semantics so different users do not collide into one # session key. + # + # If Slack includes thread context in the slash payload, preserve it so + # session-scoped commands like `/model ` affect exactly the same + # Slack thread/session that normal messages in that thread use. Without + # this, `/model` from a thread is keyed only by channel+user, so the + # next threaded message misses the override and appears to require + # --global. Slack's native slash-command payloads vary by surface, so + # accept a few known shapes (top-level and nested, preferring a real + # parent-thread anchor over a fallback message timestamp) and otherwise + # leave thread_id unset; users can always use the message-based + # ``!model ...`` thread command path, which carries event.thread_ts. + thread_id = None + _thread_candidates = [command] + for _nested_key in ("message", "container"): + _nested = command.get(_nested_key) + if isinstance(_nested, dict): + _thread_candidates.append(_nested) + for _ts_key in ("thread_ts", "message_ts"): + for _payload in _thread_candidates: + _value = _payload.get(_ts_key) + if _value: + thread_id = str(_value) + break + if thread_id: + break is_dm = str(channel_id).startswith("D") source = self.build_source( chat_id=channel_id, chat_type="dm" if is_dm else "group", user_id=user_id, + thread_id=thread_id, scope_id=team_id or None, ) diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 00d2fdd45b93..fbcaa514fe87 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -171,6 +171,34 @@ class TestSlashCommandSessionIsolation: assert event.source.user_id == "U123" assert event.source.scope_id == "T123" + @pytest.mark.asyncio + async def test_slash_command_preserves_thread_id_when_payload_includes_it(self, adapter): + """Thread-scoped commands such as /model must key to the Slack thread. + + If the slash payload carries thread_ts but the adapter drops it, a + session-only /model switch is stored under the channel/user key while + the next normal threaded message is stored under channel/thread_ts, so + the override is missed and users are forced to use --global. + """ + command = { + "command": "/model", + "text": "qwen --provider openrouter", + "user_id": "U123", + "channel_id": "C123", + "team_id": "T123", + "thread_ts": "1700000000.123456", + } + + await adapter._handle_slash_command(command) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "/model qwen --provider openrouter" + assert event.source.chat_type == "group" + assert event.source.chat_id == "C123" + assert event.source.user_id == "U123" + assert event.source.thread_id == "1700000000.123456" + # --------------------------------------------------------------------------- # TestAppMentionHandler @@ -1470,6 +1498,29 @@ class TestBangPrefixCommands: assert msg_event.text.startswith("/model gpt-5.4") assert msg_event.message_type == MessageType.COMMAND + @pytest.mark.asyncio + async def test_bang_command_with_rich_text_block_is_not_duplicated(self, adapter): + """Slack rich_text blocks mirror message text; bang rewrite must not duplicate args.""" + text = "!model qwen3.7-plus --provider opencode-go" + evt = self._make_event(text) + evt["blocks"] = [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": text}], + } + ], + } + ] + + await adapter._handle_slack_message(evt) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "/model qwen3.7-plus --provider opencode-go" + assert msg_event.message_type == MessageType.COMMAND + @pytest.mark.asyncio async def test_bang_works_inside_thread(self, adapter): """The whole point: ``!stop`` inside a thread reply dispatches."""