forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -0,0 +1,717 @@
|
||||
"""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 gateway.platforms.slack 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_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_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_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()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _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 = {"U1": "Alice", "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_skips_bot_messages(self):
|
||||
"""Self-bot child replies are skipped to avoid circular context,
|
||||
but non-self bots (e.g. cron posts, third-party integrations) are kept.
|
||||
|
||||
Regression guard for the fix in _fetch_thread_context: previously ALL
|
||||
bot messages were dropped, which lost context when the bot was replying
|
||||
to a cron-posted thread parent."""
|
||||
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 -> must be skipped (circular)
|
||||
{
|
||||
"ts": "1000.1",
|
||||
"bot_id": "B_SELF",
|
||||
"user": "U_BOT",
|
||||
"text": "Previous bot self-reply (should be skipped)",
|
||||
},
|
||||
# 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 = {"U1": "Alice", "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 "Previous bot self-reply" not in context
|
||||
assert "Alice: Parent" in context
|
||||
# Third-party bot message must now be included
|
||||
assert "Deploy succeeded" 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 = {"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_excludes_self_bot_replies(self):
|
||||
"""Parent (non-self bot) is kept, self-bot child replies are dropped,
|
||||
user replies are kept."""
|
||||
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 -> excluded
|
||||
{
|
||||
"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 = {"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
|
||||
assert "Previous self reply" not 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 — must be skipped
|
||||
{
|
||||
"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 = {"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
|
||||
assert "Own T2 bot reply" not 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 = {"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
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_message_ts_cap(self):
|
||||
"""Verify memory is bounded when many messages are sent."""
|
||||
adapter = _make_adapter()
|
||||
adapter._BOT_TS_MAX = 10 # low cap for testing
|
||||
mock_client = adapter._team_clients["T1"]
|
||||
|
||||
for i in range(20):
|
||||
mock_client.chat_postMessage = AsyncMock(return_value={"ts": f"{i}.0"})
|
||||
await adapter.send(chat_id="C1", content=f"msg {i}")
|
||||
|
||||
assert len(adapter._bot_message_ts) <= 10
|
||||
|
||||
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(self):
|
||||
"""Verify _mentioned_threads is bounded."""
|
||||
adapter = _make_adapter()
|
||||
adapter._MENTIONED_THREADS_MAX = 10
|
||||
for i in range(15):
|
||||
adapter._mentioned_threads.add(f"{i}.0")
|
||||
if len(adapter._mentioned_threads) > adapter._MENTIONED_THREADS_MAX:
|
||||
to_remove = list(adapter._mentioned_threads)[:adapter._MENTIONED_THREADS_MAX // 2]
|
||||
for t in to_remove:
|
||||
adapter._mentioned_threads.discard(t)
|
||||
assert len(adapter._mentioned_threads) <= 10
|
||||
Reference in New Issue
Block a user