mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
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:
parent
8fc1f578bb
commit
fe201dd56c
2 changed files with 86 additions and 1 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue