"""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_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
