hermes-agent/tests/gateway/test_slack_approval_buttons.py
Teknium 558dab0e3e feat(slack): opt-in reaction triggers, removed events, hooks, channel handoff
Build the full reaction pipeline on top of the #29916 base:

- Opt-in gate: slack.reaction_triggers (default OFF — reaction events
  stay acked-and-dropped so busy channels don't wake the agent on every
  emoji). 'true' routes reactions on the bot's OWN messages; an explicit
  emoji-name list routes those emojis from any message (handoff flows).
- reaction_removed events now route too, distinguished by the
  cross-platform text convention reaction:added:<emoji> /
  reaction:removed:<emoji> (matches the Feishu and Photon adapters, so
  agents and skills see one shape everywhere).
- Authorization: the reactor becomes the synthesized message's user, so
  the early _is_user_authorized gate and allowed_channels whitelist
  apply exactly as for typed messages. _hermes_force_process only skips
  the mention requirement (a reaction on the bot's own message is
  definitionally addressed to the bot), mirroring Feishu/Photon.
- Gateway hooks (#33111 by @johnkattenhorn): every human reaction on a
  message item fires reaction:added / reaction:removed through the new
  BasePlatformAdapter.set_reaction_handler → GatewayRunner
  ._handle_reaction_event → HookRegistry.emit, independent of the
  routing opt-in. Documented in hooks.md.
- Channel handoff (#45265 by @Kev-fs): slack.reaction_trigger_target
  routes the reaction turn to a configured channel (top-level via
  _hermes_no_thread_response + reply-anchor suppression in
  gateway/platforms/base.py) or C123:<ts> thread.
- Manifest: reaction_removed event subscription added alongside
  reaction_added/reactions:read.
- Docs: slack.md Reaction Triggers section; hooks.md event table rows.

Also credits #44508 by @harrisonmedmedmetrics (inbound reaction_added
handling — same plumbing class, superseded by this consolidated shape).

Co-authored-by: johnkattenhorn <john.kattenhorn.personal@gmail.com>
Co-authored-by: Kev-fs <kevin@fleetsmarts.net>
Co-authored-by: harrisonmedmedmetrics <harrison@medmetricsrx.com>
2026-07-23 11:50:08 -07:00

1652 lines
64 KiB
Python

"""Tests for Slack Block Kit approval buttons and thread context fetching."""
import asyncio
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Ensure the repo root is importable
# ---------------------------------------------------------------------------
_repo = str(Path(__file__).resolve().parents[2])
if _repo not in sys.path:
sys.path.insert(0, _repo)
# ---------------------------------------------------------------------------
# Minimal Slack SDK mock so SlackAdapter can be imported
# ---------------------------------------------------------------------------
def _ensure_slack_mock():
"""Wire up the minimal mocks required to import SlackAdapter."""
if "slack_bolt" in sys.modules:
return
slack_bolt = MagicMock()
slack_bolt.async_app.AsyncApp = MagicMock
sys.modules["slack_bolt"] = slack_bolt
sys.modules["slack_bolt.async_app"] = slack_bolt.async_app
handler_mod = MagicMock()
handler_mod.AsyncSocketModeHandler = MagicMock
sys.modules["slack_bolt.adapter"] = MagicMock()
sys.modules["slack_bolt.adapter.socket_mode"] = MagicMock()
sys.modules["slack_bolt.adapter.socket_mode.async_handler"] = handler_mod
sdk_mod = MagicMock()
sdk_mod.web = MagicMock()
sdk_mod.web.async_client = MagicMock()
sdk_mod.web.async_client.AsyncWebClient = MagicMock
sys.modules["slack_sdk"] = sdk_mod
sys.modules["slack_sdk.web"] = sdk_mod.web
sys.modules["slack_sdk.web.async_client"] = sdk_mod.web.async_client
_ensure_slack_mock()
from plugins.platforms.slack.adapter import SlackAdapter
from gateway.config import PlatformConfig, Platform
def _make_adapter():
"""Create a SlackAdapter instance with mocked internals."""
config = PlatformConfig(enabled=True, token="xoxb-test-token")
adapter = SlackAdapter(config)
adapter._app = MagicMock()
adapter._bot_user_id = "U_BOT"
adapter._team_clients = {"T1": AsyncMock()}
adapter._team_bot_user_ids = {"T1": "U_BOT"}
adapter._channel_team = {"C1": "T1"}
return adapter
class _AuthRunner:
def __init__(self, auth_fn=None):
self._auth_fn = auth_fn or (lambda _source: True)
self.seen_sources = []
async def handle(self, event):
return None
def _is_user_authorized(self, source):
self.seen_sources.append(source)
return self._auth_fn(source)
def _attach_auth_runner(adapter, auth_fn=None):
runner = _AuthRunner(auth_fn=auth_fn)
adapter.set_message_handler(runner.handle)
return runner
# ===========================================================================
# send_exec_approval — Block Kit buttons
# ===========================================================================
class TestSlackExecApproval:
"""Test the send_exec_approval method sends Block Kit buttons."""
@pytest.mark.asyncio
async def test_sends_blocks_with_buttons(self):
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.chat_postMessage = AsyncMock(return_value={"ts": "1234.5678"})
result = await adapter.send_exec_approval(
chat_id="C1",
command="rm -rf /important",
session_key="agent:main:slack:group:C1:1111",
description="dangerous deletion",
)
assert result.success is True
assert result.message_id == "1234.5678"
# Verify chat_postMessage was called with blocks
mock_client.chat_postMessage.assert_called_once()
kwargs = mock_client.chat_postMessage.call_args[1]
assert "blocks" in kwargs
blocks = kwargs["blocks"]
assert len(blocks) == 2
assert blocks[0]["type"] == "section"
assert "rm -rf /important" in blocks[0]["text"]["text"]
assert "dangerous deletion" in blocks[0]["text"]["text"]
assert blocks[1]["type"] == "actions"
elements = blocks[1]["elements"]
assert len(elements) == 4
action_ids = [e["action_id"] for e in elements]
assert "hermes_approve_once" in action_ids
assert "hermes_approve_session" in action_ids
assert "hermes_approve_always" in action_ids
assert "hermes_deny" in action_ids
# Each button carries the session key as value
for e in elements:
assert e["value"] == "agent:main:slack:group:C1:1111"
@pytest.mark.asyncio
async def test_smart_deny_owner_override_hides_persistent_buttons(self):
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.chat_postMessage = AsyncMock(return_value={"ts": "1234.5678"})
await adapter.send_exec_approval(
chat_id="C1", command="rm -rf /", session_key="s",
allow_permanent=False, smart_denied=True,
)
kwargs = mock_client.chat_postMessage.call_args.kwargs
elements = kwargs["blocks"][1]["elements"]
assert [element["action_id"] for element in elements] == [
"hermes_approve_once", "hermes_deny",
]
assert "one operation" in kwargs["blocks"][0]["text"]["text"].lower()
@pytest.mark.asyncio
async def test_sends_in_thread(self):
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.chat_postMessage = AsyncMock(return_value={"ts": "1234.5678"})
await adapter.send_exec_approval(
chat_id="C1",
command="echo test",
session_key="test-session",
metadata={"thread_id": "9999.0000"},
)
kwargs = mock_client.chat_postMessage.call_args[1]
assert kwargs.get("thread_ts") == "9999.0000"
@pytest.mark.asyncio
async def test_not_connected(self):
adapter = _make_adapter()
adapter._app = None
result = await adapter.send_exec_approval(
chat_id="C1", command="ls", session_key="s"
)
assert result.success is False
@pytest.mark.asyncio
async def test_truncates_long_command(self):
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.chat_postMessage = AsyncMock(return_value={"ts": "1.2"})
long_cmd = "x" * 5000
await adapter.send_exec_approval(
chat_id="C1", command=long_cmd, session_key="s"
)
kwargs = mock_client.chat_postMessage.call_args[1]
section_text = kwargs["blocks"][0]["text"]["text"]
assert "..." in section_text
assert len(section_text) < 5000
# ===========================================================================
# _handle_approval_action — button click handler
# ===========================================================================
class TestSlackApprovalAction:
"""Test the approval button click handler."""
@pytest.mark.asyncio
async def test_resolves_approval(self):
adapter = _make_adapter()
_attach_auth_runner(adapter)
adapter._approval_resolved["1234.5678"] = False
ack = AsyncMock()
body = {
"message": {
"ts": "1234.5678",
"blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": "original text"}},
{"type": "actions", "elements": []},
],
},
"channel": {"id": "C1"},
"user": {"name": "norbert", "id": "U_NORBERT"},
}
action = {
"action_id": "hermes_approve_once",
"value": "agent:main:slack:group:C1:1111",
}
mock_client = adapter._team_clients["T1"]
mock_client.chat_update = AsyncMock()
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
await adapter._handle_approval_action(ack, body, action)
ack.assert_called_once()
mock_resolve.assert_called_once_with("agent:main:slack:group:C1:1111", "once")
# Message should be updated with decision
mock_client.chat_update.assert_called_once()
update_kwargs = mock_client.chat_update.call_args[1]
assert "Approved once by norbert" in update_kwargs["text"]
@pytest.mark.asyncio
async def test_prevents_double_click(self):
adapter = _make_adapter()
_attach_auth_runner(adapter)
adapter._approval_resolved["1234.5678"] = True # Already resolved
ack = AsyncMock()
body = {
"message": {"ts": "1234.5678", "blocks": []},
"channel": {"id": "C1"},
"user": {"name": "norbert", "id": "U_NORBERT"},
}
action = {
"action_id": "hermes_approve_once",
"value": "some-session",
}
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
await adapter._handle_approval_action(ack, body, action)
# Should have acked but NOT resolved
ack.assert_called_once()
mock_resolve.assert_not_called()
@pytest.mark.asyncio
async def test_deny_action(self):
adapter = _make_adapter()
_attach_auth_runner(adapter)
adapter._approval_resolved["1.2"] = False
ack = AsyncMock()
body = {
"message": {"ts": "1.2", "blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": "cmd"}},
]},
"channel": {"id": "C1"},
"user": {"name": "alice", "id": "U_ALICE"},
}
action = {"action_id": "hermes_deny", "value": "session-key"}
mock_client = adapter._team_clients["T1"]
mock_client.chat_update = AsyncMock()
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
await adapter._handle_approval_action(ack, body, action)
mock_resolve.assert_called_once_with("session-key", "deny")
update_kwargs = mock_client.chat_update.call_args[1]
assert "Denied by alice" in update_kwargs["text"]
@pytest.mark.asyncio
async def test_truncates_inflated_original_text(self):
"""Interaction payload re-escapes HTML entities; text must be capped."""
adapter = _make_adapter()
_attach_auth_runner(adapter)
adapter._approval_resolved["1.2"] = False
# Simulate Slack re-escaping: original was ~2990 chars, but & → &amp;
# etc. inflates it past 3000.
inflated_text = "a" * 2990 + "&amp;" * 10 # 2990 + 50 = 3040 chars
ack = AsyncMock()
body = {
"message": {"ts": "1.2", "blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": inflated_text}},
]},
"channel": {"id": "C1"},
"user": {"name": "alice", "id": "U_ALICE"},
}
action = {"action_id": "hermes_approve_once", "value": "session-key"}
mock_client = adapter._team_clients["T1"]
mock_client.chat_update = AsyncMock()
with patch("tools.approval.resolve_gateway_approval", return_value=1):
await adapter._handle_approval_action(ack, body, action)
update_kwargs = mock_client.chat_update.call_args[1]
section_text = update_kwargs["blocks"][0]["text"]["text"]
assert len(section_text) <= 3000
@pytest.mark.asyncio
async def test_global_allowlist_blocks_unauthorized_click(self, monkeypatch):
adapter = _make_adapter()
adapter._approval_resolved["1234.5678"] = False
monkeypatch.delenv("SLACK_ALLOWED_USERS", raising=False)
monkeypatch.delenv("SLACK_ALLOW_ALL_USERS", raising=False)
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "U_OWNER")
ack = AsyncMock()
body = {
"message": {"ts": "1234.5678", "blocks": []},
"channel": {"id": "C1"},
"user": {"name": "mallory", "id": "U_ATTACKER"},
}
action = {
"action_id": "hermes_approve_once",
"value": "agent:main:slack:group:C1:1111",
}
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
await adapter._handle_approval_action(ack, body, action)
ack.assert_called_once()
mock_resolve.assert_not_called()
class TestSlackInteractiveAuth:
def test_delegates_to_gateway_runner_auth(self):
adapter = _make_adapter()
runner = _attach_auth_runner(adapter, auth_fn=lambda source: source.user_id == "U_OK")
assert adapter._is_interactive_user_authorized(
"U_OK",
channel_id="C1",
user_name="operator",
) is True
assert adapter._is_interactive_user_authorized(
"U_BAD",
channel_id="C1",
user_name="intruder",
) is False
assert len(runner.seen_sources) == 2
assert runner.seen_sources[0].platform == Platform.SLACK
assert runner.seen_sources[0].chat_id == "C1"
assert runner.seen_sources[0].chat_type == "group"
def test_passes_workspace_scope_to_gateway_runner_auth(self):
adapter = _make_adapter()
runner = _attach_auth_runner(adapter)
assert adapter._is_interactive_user_authorized(
"U_OK",
channel_id="C1",
user_name="operator",
team_id="T1",
) is True
assert runner.seen_sources[0].scope_id == "T1"
class TestSlackSlashConfirmAction:
@pytest.mark.asyncio
async def test_global_allowlist_allows_authorized_click(self, monkeypatch):
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.chat_update = AsyncMock()
mock_client.chat_postMessage = AsyncMock()
monkeypatch.delenv("SLACK_ALLOWED_USERS", raising=False)
monkeypatch.delenv("SLACK_ALLOW_ALL_USERS", raising=False)
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "U_OWNER")
ack = AsyncMock()
body = {
"message": {
"ts": "2222.3333",
"blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": "Original prompt"}},
],
},
"channel": {"id": "C1"},
"user": {"name": "owner", "id": "U_OWNER"},
}
action = {
"action_id": "hermes_confirm_once",
"value": "agent:main:slack:group:C1:1111|confirm-1",
}
with patch("tools.slash_confirm.resolve", new=AsyncMock(return_value="follow-up")) as mock_resolve:
await adapter._handle_slash_confirm_action(ack, body, action)
ack.assert_called_once()
mock_resolve.assert_awaited_once_with(
"agent:main:slack:group:C1:1111",
"confirm-1",
"once",
)
mock_client.chat_update.assert_called_once()
mock_client.chat_postMessage.assert_called_once()
@pytest.mark.asyncio
async def test_action_uses_outer_payload_workspace_client(self, monkeypatch):
adapter = _make_adapter()
secondary_client = AsyncMock()
adapter._team_clients["T2"] = secondary_client
monkeypatch.delenv("SLACK_ALLOWED_USERS", raising=False)
monkeypatch.delenv("SLACK_ALLOW_ALL_USERS", raising=False)
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "U_OWNER")
ack = AsyncMock()
body = {
"team_id": "T2",
"message": {
"ts": "2222.3333",
"blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": "Original prompt"}},
],
},
"channel": {"id": "C1"},
"user": {"name": "owner", "id": "U_OWNER"},
}
action = {
"action_id": "hermes_confirm_once",
"value": "agent:main:slack:group:C1:1111|confirm-1",
}
with patch("tools.slash_confirm.resolve", new=AsyncMock(return_value="follow-up")):
await adapter._handle_slash_confirm_action(ack, body, action)
secondary_client.chat_update.assert_awaited_once()
secondary_client.chat_postMessage.assert_awaited_once()
adapter._team_clients["T1"].chat_update.assert_not_called()
@pytest.mark.asyncio
async def test_truncates_inflated_original_text(self):
"""Interaction payload re-escapes HTML entities; text must be capped."""
adapter = _make_adapter()
_attach_auth_runner(adapter)
adapter._approval_resolved["2222.3333"] = False
# Simulate Slack re-escaping inflating text past 3000 chars.
inflated_text = "b" * 2990 + "&lt;" * 10 # 2990 + 40 = 3030 chars
ack = AsyncMock()
body = {
"message": {"ts": "2222.3333", "blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": inflated_text}},
]},
"channel": {"id": "C1"},
"user": {"name": "owner", "id": "U_OWNER"},
}
action = {
"action_id": "hermes_confirm_once",
"value": "agent:main:slack:group:C1:1111|confirm-1",
}
mock_client = adapter._team_clients["T1"]
mock_client.chat_update = AsyncMock()
mock_client.chat_postMessage = AsyncMock()
with patch("tools.slash_confirm.resolve", new=AsyncMock(return_value="ok")):
await adapter._handle_slash_confirm_action(ack, body, action)
update_kwargs = mock_client.chat_update.call_args[1]
section_text = update_kwargs["blocks"][0]["text"]["text"]
assert len(section_text) <= 3000
# ===========================================================================
# _fetch_thread_context
# ===========================================================================
class TestSlackThreadContext:
"""Test thread context fetching."""
@pytest.mark.asyncio
async def test_fetches_and_formats_context(self):
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "1000.0", "user": "U1", "text": "This is the parent message"},
{"ts": "1000.1", "user": "U2", "text": "I think we should refactor"},
{"ts": "1000.2", "user": "U1", "text": "Good idea, <@U_BOT> what do you think?"},
]
})
# Mock user name resolution
adapter._user_name_cache = {("T1", "U1"): "Alice", ("T1", "U2"): "Bob"}
context = await adapter._fetch_thread_context(
channel_id="C1",
thread_ts="1000.0",
current_ts="1000.2", # The message that triggered the fetch
team_id="T1",
)
assert "[Thread context" in context
assert "[thread parent] Alice: This is the parent message" in context
assert "Bob: I think we should refactor" in context
# Current message should be excluded
assert "what do you think" not in context
# Bot mention should be stripped from context
assert "<@U_BOT>" not in context
@pytest.mark.asyncio
async def test_includes_self_bot_replies_as_assistant_on_cold_start(self):
"""Cold-start contract (issue #38861): self-bot replies in the thread
must be included in the context, labelled with an ``[assistant]``
prefix so the agent can reconstruct its own prior turns. This method
only runs on the cold-start path (guarded at the call site by
``_has_active_session_for_thread``) — when an active session exists,
the session history already carries those replies, so there is no
risk of circular duplication.
Third-party bots (e.g. deploy notifications) must still be kept,
attributed to their display name."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "1000.0", "user": "U1", "text": "Parent"},
# Self-bot reply -> kept on cold-start, prefixed [assistant]
{
"ts": "1000.1",
"bot_id": "B_SELF",
"user": "U_BOT",
"text": "Previous bot self-reply",
},
# Third-party bot child -> kept (useful context)
{
"ts": "1000.15",
"bot_id": "B_OTHER",
"user": "U_OTHER_BOT",
"text": "Deploy succeeded",
},
{"ts": "1000.2", "user": "U1", "text": "Current"},
]
})
adapter._user_name_cache = {
("T1", "U1"): "Alice",
("T1", "U_OTHER_BOT"): "DeployBot",
}
context = await adapter._fetch_thread_context(
channel_id="C1", thread_ts="1000.0", current_ts="1000.2", team_id="T1"
)
assert "Alice: Parent" in context
# Self-bot reply must now be included with [assistant] label
assert "[assistant] Previous bot self-reply" in context
# Third-party bot message must still be included
assert "Deploy succeeded" in context
# The [assistant] label must NOT leak to user messages
assert "[assistant] Alice" not in context
@pytest.mark.asyncio
async def test_empty_thread(self):
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={"messages": []})
context = await adapter._fetch_thread_context(
channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"
)
assert context == ""
@pytest.mark.asyncio
async def test_api_failure_returns_empty(self):
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(side_effect=Exception("API error"))
context = await adapter._fetch_thread_context(
channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"
)
assert context == ""
@pytest.mark.asyncio
async def test_fetch_thread_context_includes_bot_parent(self):
"""The thread parent posted by a bot (e.g. a cron summary) must be
included in the context, prefixed with ``[thread parent]``."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
# Bot-posted parent (cron job)
{
"ts": "1000.0",
"bot_id": "B123",
"subtype": "bot_message",
"username": "cron",
"text": "メール要約: 本日の新着3件",
},
# User reply that triggered the fetch
{"ts": "1000.1", "user": "U1", "text": "詳細を教えて"},
]
})
adapter._user_name_cache = {("T1", "U1"): "Alice"}
context = await adapter._fetch_thread_context(
channel_id="C1",
thread_ts="1000.0",
current_ts="1000.1", # exclude the trigger message itself
team_id="T1",
)
assert "[thread parent]" in context
assert "メール要約: 本日の新着3件" in context
@pytest.mark.asyncio
async def test_fetch_thread_context_extracts_block_kit_parent(self):
"""Bot-posted parents that put their content in ``blocks`` (Honeycomb,
PagerDuty, Datadog, GitHub bot, etc.) used to be reduced to just the
``text`` field — typically only the alert title — which dropped the
URL/button payload that makes the alert useful to an agent replying
in the thread. The fetched context must now include bounded display
text and actionable URLs so section text and button URLs survive."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
# Bot-posted alert: title in `text`, URL only in `blocks`.
# Mirrors what Honeycomb, PagerDuty, etc. actually send.
{
"ts": "1000.0",
"bot_id": "B_ALERT",
"subtype": "bot_message",
"username": "alertbot",
"text": "low_alerts (checkout)",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Trigger fired:* low_alerts",
},
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "View graph"},
"url": "https://example.example/view/abc123",
},
],
},
],
},
# User reply that triggered the fetch.
{"ts": "1000.1", "user": "U1", "text": "what's going on?"},
]
})
adapter._user_name_cache = {"U1": "Alice"}
context = await adapter._fetch_thread_context(
channel_id="C1",
thread_ts="1000.0",
current_ts="1000.1",
team_id="T1",
)
# Title still present.
assert "low_alerts (checkout)" in context
# URL from the action button must now surface.
assert "https://example.example/view/abc123" in context
# Marked as the thread parent.
assert "[thread parent]" in context
@pytest.mark.asyncio
async def test_fetch_thread_context_includes_blocks_only_parent(self):
"""A parent message with empty ``text`` but non-empty ``blocks`` must
still be included — without this, alerts that put *everything* in
``blocks`` (some webhook integrations do this) are silently dropped
because the ``if not msg_text: continue`` guard fires."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{
"ts": "1000.0",
"bot_id": "B_ALERT",
"subtype": "bot_message",
"username": "alertbot",
"text": "",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Build failed: <https://example.example/build/9|#9>",
},
},
],
},
{"ts": "1000.1", "user": "U1", "text": "looking"},
]
})
adapter._user_name_cache = {"U1": "Alice"}
context = await adapter._fetch_thread_context(
channel_id="C1",
thread_ts="1000.0",
current_ts="1000.1",
team_id="T1",
)
assert "[thread parent]" in context
assert "https://example.example/build/9" in context
@pytest.mark.asyncio
async def test_fetch_thread_parent_text_surfaces_block_urls(self):
"""Cold-cache _fetch_thread_parent_text must use the same renderer as
_fetch_thread_context so a bot-posted parent with a URL only in
``blocks`` surfaces it in reply_to_text, not just in thread context."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{
"ts": "1000.0",
"bot_id": "B_ALERT",
"subtype": "bot_message",
"username": "alertbot",
"text": "Incident triggered",
"blocks": [
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "View incident"},
"url": "https://example.example/incident/42",
},
],
},
],
},
]
})
text = await adapter._fetch_thread_parent_text(
channel_id="C1",
thread_ts="1000.0",
team_id="T1",
)
assert "Incident triggered" in text
assert "https://example.example/incident/42" in text
@pytest.mark.asyncio
async def test_fetch_thread_context_includes_self_bot_replies_with_assistant_label(self):
"""Cold-start: parent (non-self bot) kept with [thread parent],
self-bot child replies kept with [assistant] label, user replies
kept unchanged. The cold-start path is the ONLY caller of this
method; circular-context risk does not apply here (see #38861)."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "1000.0", "bot_id": "B_CRON", "text": "Cron summary"},
# Self-bot child reply -> kept with [assistant] label
{
"ts": "1000.1",
"bot_id": "B_SELF",
"user": "U_BOT", # matches adapter._bot_user_id
"text": "Previous self reply",
},
# User reply -> kept
{"ts": "1000.2", "user": "U1", "text": "Follow-up question"},
# Current trigger (excluded by current_ts match)
{"ts": "1000.3", "user": "U1", "text": "Current"},
]
})
adapter._user_name_cache = {("T1", "U1"): "Alice"}
context = await adapter._fetch_thread_context(
channel_id="C1", thread_ts="1000.0", current_ts="1000.3", team_id="T1"
)
assert "Cron summary" in context
assert "[thread parent]" in context
# Self-bot child reply is now kept with the [assistant] label
assert "[assistant] Previous self reply" in context
assert "Follow-up question" in context
assert "Current" not in context
@pytest.mark.asyncio
async def test_fetch_thread_context_multi_workspace(self):
"""Self-bot filtering must use the per-workspace bot user id so a
self-bot id that belongs to a different workspace does not accidentally
filter out a legitimate message in the current workspace."""
adapter = _make_adapter()
# Add a second workspace with a different bot user id
adapter._team_clients["T2"] = AsyncMock()
adapter._team_bot_user_ids = {"T1": "U_BOT_T1", "T2": "U_BOT_T2"}
adapter._bot_user_id = "U_BOT_T1"
adapter._channel_team["C2"] = "T2"
mock_client = adapter._team_clients["T2"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "2000.0", "user": "U2", "text": "Parent T2"},
# This has the *T1* bot's user id — from T2's perspective this
# is a third-party bot, so it must be kept.
{
"ts": "2000.1",
"bot_id": "B_FOREIGN",
"user": "U_BOT_T1",
"team": "T2",
"text": "Cross-workspace bot reply",
},
# Self-bot for T2 — kept with the [assistant] label
{
"ts": "2000.2",
"bot_id": "B_SELF_T2",
"user": "U_BOT_T2",
"team": "T2",
"text": "Own T2 bot reply",
},
{"ts": "2000.3", "user": "U2", "text": "Current"},
]
})
adapter._user_name_cache = {("T2", "U2"): "Bob"}
context = await adapter._fetch_thread_context(
channel_id="C2", thread_ts="2000.0", current_ts="2000.3", team_id="T2"
)
assert "Parent T2" in context
assert "Cross-workspace bot reply" in context
# T2's own self-bot reply is kept with the [assistant] label
# (cold-start path includes self-replies; see #38861). The
# per-workspace filter still applies: this assertion confirms
# we use T2's bot id, not T1's, when deciding what counts as
# self-bot.
assert "[assistant] Own T2 bot reply" in context
@pytest.mark.asyncio
async def test_fetch_thread_context_current_ts_excluded(self):
"""Regression guard: the message whose ts == current_ts must never
appear in the context output (it will be delivered as the user
message itself)."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "1000.0", "user": "U1", "text": "Parent"},
{"ts": "1000.1", "user": "U1", "text": "DO NOT INCLUDE THIS"},
]
})
adapter._user_name_cache = {("T1", "U1"): "Alice"}
context = await adapter._fetch_thread_context(
channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"
)
assert "Parent" in context
assert "DO NOT INCLUDE THIS" not in context
@pytest.mark.asyncio
async def test_fetch_thread_parent_text_from_cache(self):
"""_fetch_thread_parent_text should reuse the thread-context cache
when it is warm, avoiding an extra conversations.replies call."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "1000.0", "bot_id": "B123", "text": "Parent summary"},
{"ts": "1000.1", "user": "U1", "text": "reply"},
]
})
# Warm the cache via _fetch_thread_context
await adapter._fetch_thread_context(
channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"
)
assert mock_client.conversations_replies.await_count == 1
parent = await adapter._fetch_thread_parent_text(
channel_id="C1", thread_ts="1000.0", team_id="T1"
)
assert parent == "Parent summary"
# No additional API call
assert mock_client.conversations_replies.await_count == 1
# ===========================================================================
# _has_active_session_for_thread — session key fix (#5833)
# ===========================================================================
class TestSessionKeyFix:
"""Test that _has_active_session_for_thread uses build_session_key."""
def test_uses_build_session_key(self):
"""Verify the fix uses build_session_key instead of manual key construction."""
adapter = _make_adapter()
# Mock session store with a known entry
mock_store = MagicMock()
mock_store._entries = {
"agent:main:slack:group:C1:1000.0": MagicMock()
}
mock_store._ensure_loaded = MagicMock()
mock_store.config = MagicMock()
mock_store.config.group_sessions_per_user = False # threads don't include user_id
mock_store.config.thread_sessions_per_user = False
adapter._session_store = mock_store
# With the fix, build_session_key should be called which respects
# group_sessions_per_user=False (no user_id appended)
result = adapter._has_active_session_for_thread(
channel_id="C1", thread_ts="1000.0", user_id="U123"
)
# Should find the session because build_session_key with
# group_sessions_per_user=False doesn't append user_id
assert result is True
def test_no_session_returns_false(self):
adapter = _make_adapter()
mock_store = MagicMock()
mock_store._entries = {}
mock_store._ensure_loaded = MagicMock()
mock_store.config = MagicMock()
mock_store.config.group_sessions_per_user = True
mock_store.config.thread_sessions_per_user = False
adapter._session_store = mock_store
result = adapter._has_active_session_for_thread(
channel_id="C1", thread_ts="1000.0", user_id="U123"
)
assert result is False
def test_no_session_store(self):
adapter = _make_adapter()
# No _session_store attribute
result = adapter._has_active_session_for_thread(
channel_id="C1", thread_ts="1000.0", user_id="U123"
)
assert result is False
def test_stale_session_returns_false(self):
"""A session key that exists but would be rolled by the reset policy
must NOT count as active — otherwise the reset-time first turn skips
the thread-history reseed (#55239)."""
adapter = _make_adapter()
class Store:
config = MagicMock()
config.group_sessions_per_user = False
config.thread_sessions_per_user = False
_entries = {
"agent:main:slack:group:C1:1000.0": MagicMock()
}
def _ensure_loaded(self):
return None
def _should_reset(self, entry, source):
return "idle"
adapter._session_store = Store()
result = adapter._has_active_session_for_thread(
channel_id="C1", thread_ts="1000.0", user_id="U123"
)
assert result is False
class TestSessionKeyChatType:
"""Test that _has_active_session_for_thread passes event-derived chat_type.
Regression for #39527: the old code hardcoded ``chat_type="group"``,
which produced wrong session keys for DM and MPIM threads. The fix
passes the event-derived ``chat_type`` so ``build_session_key()``
constructs the correct key for every channel type.
"""
def test_dm_thread_session_found(self):
"""IM channel (D-prefix) with an active DM session is found."""
adapter = _make_adapter()
mock_store = MagicMock()
# DM sessions key: agent:main:slack:dm:D_CHANNEL:thread_ts
mock_store._entries = {
"agent:main:slack:dm:D0DMCHANNEL:2000.0": MagicMock()
}
mock_store._ensure_loaded = MagicMock()
mock_store.config = MagicMock()
mock_store.config.group_sessions_per_user = True
mock_store.config.thread_sessions_per_user = False
adapter._session_store = mock_store
result = adapter._has_active_session_for_thread(
channel_id="D0DMCHANNEL",
thread_ts="2000.0",
user_id="U_USER",
chat_type="dm",
)
assert result is True
def test_dm_thread_not_found_with_group_type(self):
"""Without chat_type='dm', a DM session key would not match.
This is the exact bug that the old ``hardcoded "group"`` code caused:
the lookup builds ``group:…`` while the real session is ``dm:…``.
"""
adapter = _make_adapter()
mock_store = MagicMock()
mock_store._entries = {
"agent:main:slack:dm:D0DMCHANNEL:2000.0": MagicMock()
}
mock_store._ensure_loaded = MagicMock()
mock_store.config = MagicMock()
mock_store.config.group_sessions_per_user = True
mock_store.config.thread_sessions_per_user = False
adapter._session_store = mock_store
# Default chat_type="group" should NOT find the DM session
result = adapter._has_active_session_for_thread(
channel_id="D0DMCHANNEL",
thread_ts="2000.0",
user_id="U_USER",
)
assert result is False
def test_mpim_thread_session_found(self):
"""MPIM channel (G-prefix, treated as DM) with an active session is found.
MPIM channel IDs start with "G", not "D", so inferring chat_type
from the prefix would incorrectly classify this as "group".
"""
adapter = _make_adapter()
mock_store = MagicMock()
# MPIM sessions key: agent:main:slack:dm:G_MPIM_CHANNEL:thread_ts
mock_store._entries = {
"agent:main:slack:dm:G0MPIMCHANNEL:3000.0": MagicMock()
}
mock_store._ensure_loaded = MagicMock()
mock_store.config = MagicMock()
mock_store.config.group_sessions_per_user = True
mock_store.config.thread_sessions_per_user = False
adapter._session_store = mock_store
result = adapter._has_active_session_for_thread(
channel_id="G0MPIMCHANNEL",
thread_ts="3000.0",
user_id="U_USER",
chat_type="dm", # event-derived: mpim → dm
)
assert result is True
def test_mpim_thread_not_found_with_group_type(self):
"""Without passing chat_type='dm', MPIM sessions are invisible.
This is the specific case the reviewer flagged: the old D-prefix
heuristic would classify G-prefixed MPIM channels as "group",
missing the DM session.
"""
adapter = _make_adapter()
mock_store = MagicMock()
mock_store._entries = {
"agent:main:slack:dm:G0MPIMCHANNEL:3000.0": MagicMock()
}
mock_store._ensure_loaded = MagicMock()
mock_store.config = MagicMock()
mock_store.config.group_sessions_per_user = True
mock_store.config.thread_sessions_per_user = False
adapter._session_store = mock_store
# Default chat_type="group" → builds group key → no match
result = adapter._has_active_session_for_thread(
channel_id="G0MPIMCHANNEL",
thread_ts="3000.0",
user_id="U_USER",
)
assert result is False
# ===========================================================================
# Thread engagement — bot-started threads & mentioned threads
# ===========================================================================
class TestThreadEngagement:
"""Test _bot_message_ts and _mentioned_threads tracking."""
@pytest.mark.asyncio
async def test_send_tracks_bot_message_ts(self):
"""Bot's sent messages are tracked so thread replies work without @mention."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.chat_postMessage = AsyncMock(return_value={"ts": "9000.1"})
await adapter.send(chat_id="C1", content="Hello!", metadata={"thread_id": "8000.0"})
assert "9000.1" in adapter._bot_message_ts
# Thread root should also be tracked
assert "8000.0" in adapter._bot_message_ts
def test_bot_message_ts_cap_evicts_oldest_timestamps(self):
"""Bot thread tracking evicts the oldest Slack timestamps first."""
adapter = _make_adapter()
adapter._BOT_TS_MAX = 4
for ts in [
"1000.000002",
"999.999999",
"1000.000004",
"1000.000001",
"1000.000003",
]:
adapter._record_uploaded_file_thread("C1", ts)
assert adapter._bot_message_ts == {"1000.000003", "1000.000004"}
def test_mentioned_threads_populated_on_mention(self):
"""When bot is @mentioned in a thread, that thread is tracked."""
adapter = _make_adapter()
# Simulate what _handle_slack_message does on mention
adapter._mentioned_threads.add("1000.0")
assert "1000.0" in adapter._mentioned_threads
def test_mentioned_threads_cap_evicts_oldest_timestamps(self):
"""Mentioned-thread tracking evicts the oldest Slack timestamps first."""
adapter = _make_adapter()
adapter._MENTIONED_THREADS_MAX = 4
adapter._mentioned_threads.update(
{
"1000.000002",
"999.999999",
"1000.000004",
"1000.000001",
"1000.000003",
}
)
adapter._trim_mentioned_threads()
assert adapter._mentioned_threads == {
"1000.000002",
"1000.000003",
"1000.000004",
}
# ===========================================================================
# _handle_slack_reaction — reaction_added forwarding
# ===========================================================================
class TestSlackReactionForwarding:
"""Reactions should flow through the same pipeline as typed messages,
gated by the ``reaction_triggers`` opt-in (default: off)."""
@staticmethod
def _enable_triggers(adapter, value=True):
adapter.config.extra["reaction_triggers"] = value
@pytest.mark.asyncio
async def test_default_off_reaction_acked_and_dropped(self):
"""Without the reaction_triggers opt-in, reaction events are acked
and dropped — the historical behavior — so busy channels don't wake
the agent on every emoji."""
adapter = _make_adapter()
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "2000.0"},
"item_user": "U_BOT",
"event_ts": "3000.0",
})
assert forwarded == []
@pytest.mark.asyncio
async def test_reaction_synthesizes_message_in_thread(self):
"""A 👍 reaction on a message in a thread should produce a synthesized
MessageEvent that lands in that thread with text ``reaction:added:👍``,
going through _handle_slack_message so the auth gate, thread-context
fetch, and skill routing all apply unchanged."""
adapter = _make_adapter()
self._enable_triggers(adapter)
mock_client = adapter._team_clients["T1"]
# Reacted-to message is itself a reply inside a thread; its thread_ts
# points at the thread parent.
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "2000.0", "thread_ts": "1000.0", "user": "U_BOT", "text": "Proposal"}
]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "2000.0"},
"item_user": "U_BOT",
"event_ts": "3000.0",
})
assert len(forwarded) == 1
synth = forwarded[0]
assert synth["type"] == "message"
assert synth["user"] == "U1"
assert synth["text"] == "reaction:added:👍"
assert synth["channel"] == "C1"
# Threaded back to the parent of the reacted-to message, not to the
# reacted-to message itself.
assert synth["thread_ts"] == "1000.0"
# Distinct synthetic ts so dedup doesn't merge with anything else.
assert synth["ts"] == "3000.0"
# Reaction metadata preserved for downstream introspection.
assert synth["_hermes_reaction"]["name"] == "thumbsup"
assert synth["_hermes_reaction"]["action"] == "added"
assert synth["_hermes_reaction"]["reacted_to_ts"] == "2000.0"
# Pre-authorized as addressed-to-the-bot (skips mention gate only).
assert synth["_hermes_force_process"] is True
@pytest.mark.asyncio
async def test_reaction_removed_synthesizes_removed_text(self):
"""reaction_removed events route with the reaction:removed: prefix so
the agent can distinguish an un-react from a react."""
adapter = _make_adapter()
self._enable_triggers(adapter)
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_BOT", "text": "Parent"}]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction(
{
"type": "reaction_removed",
"user": "U1",
"reaction": "white_check_mark",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3000.0",
},
removed=True,
)
assert len(forwarded) == 1
assert forwarded[0]["text"] == "reaction:removed:✅"
assert forwarded[0]["_hermes_reaction"]["action"] == "removed"
@pytest.mark.asyncio
async def test_self_reaction_dropped(self):
"""The bot's own reactions (e.g. the :eyes: lifecycle marker on
incoming messages) must not feed back into the pipeline."""
adapter = _make_adapter()
self._enable_triggers(adapter)
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U_BOT", # matches adapter._bot_user_id
"reaction": "eyes",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U1",
"event_ts": "3000.0",
})
assert forwarded == []
@pytest.mark.asyncio
async def test_unknown_reaction_passes_name_through(self):
"""Reactions outside the unicode emoji map still forward, with the
Slack short name in the text. Skills can match on those."""
adapter = _make_adapter()
self._enable_triggers(adapter)
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_BOT", "text": "Parent"}]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "moov-rocket", # custom workspace emoji
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3000.0",
})
assert len(forwarded) == 1
assert forwarded[0]["text"] == "reaction:added:moov-rocket"
@pytest.mark.asyncio
async def test_non_message_reaction_ignored(self):
"""File reactions and other non-message item types are dropped — we
only forward message reactions."""
adapter = _make_adapter()
self._enable_triggers(adapter)
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "thumbsup",
"item": {"type": "file", "file": "F123"},
"event_ts": "3000.0",
})
assert forwarded == []
@pytest.mark.asyncio
async def test_top_level_message_threads_to_self(self):
"""When the reacted-to message is itself the thread parent (no
thread_ts of its own), the synthesized event uses the message ts
as thread_ts."""
adapter = _make_adapter()
self._enable_triggers(adapter)
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_BOT", "text": "Parent"}]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "+1", # alias for thumbsup
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3000.0",
})
assert len(forwarded) == 1
assert forwarded[0]["text"] == "reaction:added:👍"
assert forwarded[0]["thread_ts"] == "1000.0"
@pytest.mark.asyncio
async def test_reaction_on_non_bot_message_dropped(self):
"""A reaction on a message not sent by this bot must not enter the
agent loop — matching the Feishu adapter's target-sender check."""
adapter = _make_adapter()
self._enable_triggers(adapter)
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_OTHER", "text": "Not our bot"}]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_OTHER", # not our bot
"event_ts": "3000.0",
})
assert forwarded == []
@pytest.mark.asyncio
async def test_allowlisted_emoji_routes_from_any_message(self):
"""An explicit emoji allowlist deliberately targets any message
(emoji-handoff workflows), and non-listed emoji stay dropped."""
adapter = _make_adapter()
self._enable_triggers(adapter, ["task"])
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_OTHER", "text": "Human message"}]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
# Listed emoji on a HUMAN message → routes.
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "task",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_OTHER",
"event_ts": "3000.0",
})
# Unlisted emoji → dropped even on the bot's own message.
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3001.0",
})
assert len(forwarded) == 1
assert forwarded[0]["text"] == "reaction:added:task"
@pytest.mark.asyncio
async def test_target_channel_handoff_routes_top_level(self):
"""reaction_trigger_target=C_TRAIN routes the synthesized turn to the
target channel as a new top-level message (#45265)."""
adapter = _make_adapter()
self._enable_triggers(adapter, ["task"])
adapter.config.extra["reaction_trigger_target"] = "C_TRAIN"
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_OTHER", "text": "Handoff me"}]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "task",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_OTHER",
"event_ts": "3000.0",
})
assert len(forwarded) == 1
synth = forwarded[0]
assert synth["channel"] == "C_TRAIN"
assert "thread_ts" not in synth
assert synth["_hermes_no_thread_response"] is True
assert synth["_hermes_reaction_source_channel"] == "C1"
@pytest.mark.asyncio
async def test_target_channel_with_thread_routes_to_thread(self):
"""A C123:<ts> target routes into that thread of the target channel."""
adapter = _make_adapter()
self._enable_triggers(adapter, ["task"])
adapter.config.extra["reaction_trigger_target"] = "C_TRAIN:1719.0001"
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_OTHER", "text": "Handoff me"}]
})
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "task",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_OTHER",
"event_ts": "3000.0",
})
assert len(forwarded) == 1
assert forwarded[0]["channel"] == "C_TRAIN"
assert forwarded[0]["thread_ts"] == "1719.0001"
assert "_hermes_no_thread_response" not in forwarded[0]
@pytest.mark.asyncio
async def test_hook_fires_even_when_routing_disabled(self):
"""The gateway reaction handler fires for every human reaction on a
message item, independent of the reaction_triggers opt-in."""
adapter = _make_adapter() # opt-in NOT set
hook_events: list[dict] = []
async def _hook(ctx):
hook_events.append(ctx)
adapter.set_reaction_handler(_hook)
forwarded: list[dict] = []
async def _capture(event):
forwarded.append(event)
with patch.object(adapter, "_handle_slack_message", new=_capture):
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U1",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3000.0",
})
await adapter._handle_slack_reaction(
{
"type": "reaction_removed",
"user": "U1",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3001.0",
},
removed=True,
)
assert forwarded == [] # routing stays off
assert [e["event_name"] for e in hook_events] == [
"reaction:added",
"reaction:removed",
]
assert hook_events[0]["platform"] == "slack"
assert hook_events[0]["reaction"] == "thumbsup"
assert hook_events[0]["user_id"] == "U1"
assert hook_events[0]["channel_id"] == "C1"
assert hook_events[0]["message_ts"] == "1000.0"
@pytest.mark.asyncio
async def test_hook_not_fired_for_self_reactions(self):
"""The bot's own lifecycle reactions never reach the hook surface."""
adapter = _make_adapter()
hook_events: list[dict] = []
async def _hook(ctx):
hook_events.append(ctx)
adapter.set_reaction_handler(_hook)
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U_BOT",
"reaction": "eyes",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"event_ts": "3000.0",
})
assert hook_events == []
def test_trigger_config_parsing(self):
"""reaction_triggers accepts bool / list / string forms."""
adapter = _make_adapter()
assert adapter._slack_reaction_triggers() is None # default off
adapter.config.extra["reaction_triggers"] = False
assert adapter._slack_reaction_triggers() is None
adapter.config.extra["reaction_triggers"] = True
assert adapter._slack_reaction_triggers() == set()
adapter.config.extra["reaction_triggers"] = ["thumbsup", ":task:"]
assert adapter._slack_reaction_triggers() == {"thumbsup", "task"}
adapter.config.extra["reaction_triggers"] = "thumbsup, task"
assert adapter._slack_reaction_triggers() == {"thumbsup", "task"}
adapter.config.extra["reaction_triggers"] = "all"
assert adapter._slack_reaction_triggers() == set()
adapter.config.extra["reaction_triggers"] = "false"
assert adapter._slack_reaction_triggers() is None
def test_trigger_env_fallback(self, monkeypatch):
"""SLACK_REACTION_TRIGGERS env enables routing when config is unset."""
adapter = _make_adapter()
monkeypatch.setenv("SLACK_REACTION_TRIGGERS", "true")
assert adapter._slack_reaction_triggers() == set()
monkeypatch.setenv("SLACK_REACTION_TRIGGERS", "task,karen")
assert adapter._slack_reaction_triggers() == {"task", "karen"}
def test_target_config_parsing(self):
adapter = _make_adapter()
assert adapter._slack_reaction_trigger_target() == ("", "")
adapter.config.extra["reaction_trigger_target"] = "C123"
assert adapter._slack_reaction_trigger_target() == ("C123", "")
adapter.config.extra["reaction_trigger_target"] = "C123:1710.0001"
assert adapter._slack_reaction_trigger_target() == ("C123", "1710.0001")
class TestSlackReactionAuthorizationGate:
"""The synthesized reaction event must pass through the same early
authorization rejection as typed messages — an unauthorized reactor
cannot wake the agent."""
@pytest.mark.asyncio
async def test_unauthorized_reactor_rejected_in_pipeline(self):
adapter = _make_adapter()
adapter.config.extra["reaction_triggers"] = True
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [{"ts": "1000.0", "user": "U_BOT", "text": "Proposal"}]
})
class _Runner:
def __init__(self):
self.handled = []
self.auth_checked = []
async def handle(self, event):
self.handled.append(event)
return None
def _is_user_authorized(self, source):
self.auth_checked.append(source.user_id)
return False # reject everyone
runner = _Runner()
adapter.set_message_handler(runner.handle)
adapter.handle_message = AsyncMock()
await adapter._handle_slack_reaction({
"type": "reaction_added",
"user": "U_RANDO",
"reaction": "thumbsup",
"item": {"type": "message", "channel": "C1", "ts": "1000.0"},
"item_user": "U_BOT",
"event_ts": "3000.0",
})
# The auth gate saw the reactor's user id and rejected it before
# any MessageEvent reached the gateway.
assert "U_RANDO" in runner.auth_checked
assert runner.handled == []
adapter.handle_message.assert_not_called()