mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-02 07:11:49 +00:00
Remove unused imports (F401) and duplicate/shadowed import redefinitions (F811) across the codebase using ruff's safe autofixes. No behavioral changes -- imports only. - ~1400 safe autofixes applied across 644 files (net -1072 lines) - __init__.py re-exports preserved (excluded from F401 removal so public re-export surfaces stay intact) - Re-exports that are imported or monkeypatched by tests but look unused in their defining module are kept with explicit # noqa: F401 (gateway/run.py load_dotenv; run_agent re-exports from agent.message_sanitization, agent.context_compressor, agent.retry_utils, agent.prompt_builder, agent.process_bootstrap, agent.codex_responses_adapter) - Unsafe F841 (unused-variable) fixes deliberately skipped -- those can change behavior when the RHS has side effects - ruff lints remain disabled in pyproject.toml (only PLW1514 is selected); this is a one-time cleanup, not a config change Verification: - python -m compileall: clean - pytest --collect-only: all 27161 tests collect (zero import errors) - core entry points import clean (run_agent, model_tools, cli, toolsets, hermes_state, batch_runner, gateway) - static scan: every name any test imports directly from an edited module still resolves
605 lines
23 KiB
Python
605 lines
23 KiB
Python
"""Tests for Slack Block Kit approval buttons and thread context fetching."""
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
# ===========================================================================
|
|
# 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()
|
|
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"},
|
|
}
|
|
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()
|
|
adapter._approval_resolved["1234.5678"] = True # Already resolved
|
|
|
|
ack = AsyncMock()
|
|
body = {
|
|
"message": {"ts": "1234.5678", "blocks": []},
|
|
"channel": {"id": "C1"},
|
|
"user": {"name": "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()
|
|
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"},
|
|
}
|
|
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"]
|
|
|
|
|
|
# ===========================================================================
|
|
# _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
|