hermes-agent/tests/gateway/test_slack_approval_buttons.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

843 lines
33 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()
# ===========================================================================
# _handle_approval_action — button click handler
# ===========================================================================
class TestSlackApprovalAction:
"""Test the approval button click handler."""
@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 & → &
# etc. inflates it past 3000.
inflated_text = "a" * 2990 + "&" * 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"
class TestSlackSlashConfirmAction:
@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_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_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_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
# ===========================================================================
# _has_active_session_for_thread — session key fix (#5833)
# ===========================================================================
class TestSessionKeyFix:
"""Test that _has_active_session_for_thread uses build_session_key."""
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_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
# ===========================================================================
# Thread engagement — bot-started threads & mentioned threads
# ===========================================================================
class TestThreadEngagement:
"""Test _bot_message_ts and _mentioned_threads tracking."""
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_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"
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
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()