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.
This commit is contained in:
Greg Duraj 2026-07-22 08:13:21 -07:00 committed by Teknium
parent 8fc1f578bb
commit fe201dd56c
No known key found for this signature in database
2 changed files with 86 additions and 1 deletions

View file

@ -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 <name>` 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,
)

View file

@ -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."""