From fadc1a4f183e2569406777e6fcfa854d49478750 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Mon, 9 Feb 2026 21:21:26 -0800 Subject: [PATCH 01/11] feat: add 'agent: BoltAgent' listener argument --- .gitignore | 3 + slack_bolt/__init__.py | 2 + slack_bolt/agent/__init__.py | 5 + slack_bolt/agent/agent.py | 74 +++++ slack_bolt/agent/async_agent.py | 74 +++++ slack_bolt/context/async_context.py | 33 ++- slack_bolt/context/base_context.py | 1 + slack_bolt/context/context.py | 33 ++- slack_bolt/kwargs_injection/args.py | 5 + slack_bolt/kwargs_injection/async_args.py | 5 + slack_bolt/kwargs_injection/async_utils.py | 5 +- slack_bolt/kwargs_injection/utils.py | 5 +- tests/scenario_tests/test_events_agent.py | 247 +++++++++++++++++ .../scenario_tests_async/test_events_agent.py | 254 ++++++++++++++++++ 14 files changed, 740 insertions(+), 6 deletions(-) create mode 100644 slack_bolt/agent/__init__.py create mode 100644 slack_bolt/agent/agent.py create mode 100644 slack_bolt/agent/async_agent.py create mode 100644 tests/scenario_tests/test_events_agent.py create mode 100644 tests/scenario_tests_async/test_events_agent.py diff --git a/.gitignore b/.gitignore index 2549060e7..b28dfa9ed 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ venv/ .venv* .env/ +# claude +.claude/*.local.json + # codecov / coverage .coverage cov_* diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index 6331925f8..4e43252fd 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -21,6 +21,7 @@ from .response import BoltResponse # AI Agents & Assistants +from .agent import BoltAgent from .middleware.assistant.assistant import ( Assistant, ) @@ -46,6 +47,7 @@ "CustomListenerMatcher", "BoltRequest", "BoltResponse", + "BoltAgent", "Assistant", "AssistantThreadContext", "AssistantThreadContextStore", diff --git a/slack_bolt/agent/__init__.py b/slack_bolt/agent/__init__.py new file mode 100644 index 000000000..4d83a07ea --- /dev/null +++ b/slack_bolt/agent/__init__.py @@ -0,0 +1,5 @@ +from slack_bolt.agent.agent import BoltAgent + +__all__ = [ + "BoltAgent", +] diff --git a/slack_bolt/agent/agent.py b/slack_bolt/agent/agent.py new file mode 100644 index 000000000..bbe55ba50 --- /dev/null +++ b/slack_bolt/agent/agent.py @@ -0,0 +1,74 @@ +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.web.chat_stream import ChatStream + + +class BoltAgent: + """Agent listener argument for building AI-powered Slack agents. + + Experimental: + This API is experimental and may change in future releases. + + @app.event("app_mention") + def handle_mention(agent): + stream = agent.chat_stream() + stream.append(markdown_text="Hello!") + stream.stop() + """ + + def __init__( + self, + *, + client: WebClient, + channel_id: Optional[str] = None, + thread_ts: Optional[str] = None, + team_id: Optional[str] = None, + user_id: Optional[str] = None, + ): + self._client = client + self._channel_id = channel_id + self._thread_ts = thread_ts + self._team_id = team_id + self._user_id = user_id + + def chat_stream( + self, + *, + channel: Optional[str] = None, + thread_ts: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, + **kwargs, + ) -> ChatStream: + """Creates a ChatStream with defaults from event context. + + Each call creates a new instance. Create multiple for parallel streams. + + Args: + channel: Channel ID. Defaults to the channel from the event context. + thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. + recipient_team_id: Team ID of the recipient. Defaults to the team from the event context. + recipient_user_id: User ID of the recipient. Defaults to the user from the event context. + **kwargs: Additional arguments passed to ``WebClient.chat_stream()``. + + Returns: + A new ``ChatStream`` instance. + """ + resolved_channel = channel or self._channel_id + resolved_thread_ts = thread_ts or self._thread_ts + if resolved_channel is None: + raise ValueError( + "channel is required: provide it as an argument or ensure channel_id is set in the event context" + ) + if resolved_thread_ts is None: + raise ValueError( + "thread_ts is required: provide it as an argument or ensure thread_ts is set in the event context" + ) + return self._client.chat_stream( + channel=resolved_channel, + thread_ts=resolved_thread_ts, + recipient_team_id=recipient_team_id or self._team_id, + recipient_user_id=recipient_user_id or self._user_id, + **kwargs, + ) diff --git a/slack_bolt/agent/async_agent.py b/slack_bolt/agent/async_agent.py new file mode 100644 index 000000000..7ef173d3e --- /dev/null +++ b/slack_bolt/agent/async_agent.py @@ -0,0 +1,74 @@ +from typing import Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_chat_stream import AsyncChatStream + + +class AsyncBoltAgent: + """Async agent listener argument for building AI-powered Slack agents. + + Experimental: + This API is experimental and may change in future releases. + + @app.event("app_mention") + async def handle_mention(agent): + stream = await agent.chat_stream() + await stream.append(markdown_text="Hello!") + await stream.stop() + """ + + def __init__( + self, + *, + client: AsyncWebClient, + channel_id: Optional[str] = None, + thread_ts: Optional[str] = None, + team_id: Optional[str] = None, + user_id: Optional[str] = None, + ): + self._client = client + self._channel_id = channel_id + self._thread_ts = thread_ts + self._team_id = team_id + self._user_id = user_id + + async def chat_stream( + self, + *, + channel: Optional[str] = None, + thread_ts: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, + **kwargs, + ) -> AsyncChatStream: + """Creates an AsyncChatStream with defaults from event context. + + Each call creates a new instance. Create multiple for parallel streams. + + Args: + channel: Channel ID. Defaults to the channel from the event context. + thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. + recipient_team_id: Team ID of the recipient. Defaults to the team from the event context. + recipient_user_id: User ID of the recipient. Defaults to the user from the event context. + **kwargs: Additional arguments passed to ``AsyncWebClient.chat_stream()``. + + Returns: + A new ``AsyncChatStream`` instance. + """ + resolved_channel = channel or self._channel_id + resolved_thread_ts = thread_ts or self._thread_ts + if resolved_channel is None: + raise ValueError( + "channel is required: provide it as an argument or ensure channel_id is set in the event context" + ) + if resolved_thread_ts is None: + raise ValueError( + "thread_ts is required: provide it as an argument or ensure thread_ts is set in the event context" + ) + return await self._client.chat_stream( + channel=resolved_channel, + thread_ts=resolved_thread_ts, + recipient_team_id=recipient_team_id or self._team_id, + recipient_user_id=recipient_user_id or self._user_id, + **kwargs, + ) diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 47eb4744e..3e373e55f 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import TYPE_CHECKING, Optional from slack_sdk.web.async_client import AsyncWebClient @@ -15,6 +15,9 @@ from slack_bolt.context.set_title.async_set_title import AsyncSetTitle from slack_bolt.util.utils import create_copy +if TYPE_CHECKING: + from slack_bolt.agent.async_agent import AsyncBoltAgent + class AsyncBoltContext(BaseContext): """Context object associated with a request from Slack.""" @@ -187,6 +190,34 @@ async def handle_button_clicks(context): self["fail"] = AsyncFail(client=self.client, function_execution_id=self.function_execution_id) return self["fail"] + @property + def agent(self) -> "AsyncBoltAgent": + """`agent` listener argument for building AI-powered Slack agents. + + Experimental: + This API is experimental and may change in future releases. + + @app.event("app_mention") + async def handle_mention(agent): + stream = await agent.chat_stream() + await stream.append(markdown_text="Hello!") + await stream.stop() + + Returns: + `AsyncBoltAgent` instance + """ + if "agent" not in self: + from slack_bolt.agent.async_agent import AsyncBoltAgent + + self["agent"] = AsyncBoltAgent( + client=self.client, + channel_id=self.channel_id, + thread_ts=self.thread_ts, + team_id=self.team_id, + user_id=self.user_id, + ) + return self["agent"] + @property def set_title(self) -> Optional[AsyncSetTitle]: return self.get("set_title") diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 843d5ef60..85105b783 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -38,6 +38,7 @@ class BaseContext(dict): "set_status", "set_title", "set_suggested_prompts", + "agent", ] # Note that these items are not copyable, so when you add new items to this list, # you must modify ThreadListenerRunner/AsyncioListenerRunner's _build_lazy_request method to pass the values. diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index 31edf2891..bbd001482 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import TYPE_CHECKING, Optional from slack_sdk import WebClient @@ -15,6 +15,9 @@ from slack_bolt.context.set_title import SetTitle from slack_bolt.util.utils import create_copy +if TYPE_CHECKING: + from slack_bolt.agent.agent import BoltAgent + class BoltContext(BaseContext): """Context object associated with a request from Slack.""" @@ -188,6 +191,34 @@ def handle_button_clicks(context): self["fail"] = Fail(client=self.client, function_execution_id=self.function_execution_id) return self["fail"] + @property + def agent(self) -> "BoltAgent": + """`agent` listener argument for building AI-powered Slack agents. + + Experimental: + This API is experimental and may change in future releases. + + @app.event("app_mention") + def handle_mention(agent): + stream = agent.chat_stream() + stream.append(markdown_text="Hello!") + stream.stop() + + Returns: + `BoltAgent` instance + """ + if "agent" not in self: + from slack_bolt.agent.agent import BoltAgent + + self["agent"] = BoltAgent( + client=self.client, + channel_id=self.channel_id, + thread_ts=self.thread_ts, + team_id=self.team_id, + user_id=self.user_id, + ) + return self["agent"] + @property def set_title(self) -> Optional[SetTitle]: return self.get("set_title") diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 1a0ec3ca8..113e39c08 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -8,6 +8,7 @@ from slack_bolt.context.fail import Fail from slack_bolt.context.get_thread_context.get_thread_context import GetThreadContext from slack_bolt.context.respond import Respond +from slack_bolt.agent.agent import BoltAgent from slack_bolt.context.save_thread_context import SaveThreadContext from slack_bolt.context.say import Say from slack_bolt.context.set_status import SetStatus @@ -102,6 +103,8 @@ def handle_buttons(args): """`get_thread_context()` utility function for AI Agents & Assistants""" save_thread_context: Optional[SaveThreadContext] """`save_thread_context()` utility function for AI Agents & Assistants""" + agent: Optional[BoltAgent] + """`agent` listener argument for AI Agents & Assistants""" # middleware next: Callable[[], None] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -135,6 +138,7 @@ def __init__( set_suggested_prompts: Optional[SetSuggestedPrompts] = None, get_thread_context: Optional[GetThreadContext] = None, save_thread_context: Optional[SaveThreadContext] = None, + agent: Optional[BoltAgent] = None, # As this method is not supposed to be invoked by bolt-python users, # the naming conflict with the built-in one affects # only the internals of this method @@ -168,6 +172,7 @@ def __init__( self.set_suggested_prompts = set_suggested_prompts self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context + self.agent = agent self.next: Callable[[], None] = next self.next_: Callable[[], None] = next diff --git a/slack_bolt/kwargs_injection/async_args.py b/slack_bolt/kwargs_injection/async_args.py index 4953f2167..1f1dde024 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -1,6 +1,7 @@ from logging import Logger from typing import Callable, Awaitable, Dict, Any, Optional +from slack_bolt.agent.async_agent import AsyncBoltAgent from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.context.complete.async_complete import AsyncComplete @@ -101,6 +102,8 @@ async def handle_buttons(args): """`get_thread_context()` utility function for AI Agents & Assistants""" save_thread_context: Optional[AsyncSaveThreadContext] """`save_thread_context()` utility function for AI Agents & Assistants""" + agent: Optional[AsyncBoltAgent] + """`agent` listener argument for AI Agents & Assistants""" # middleware next: Callable[[], Awaitable[None]] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -134,6 +137,7 @@ def __init__( set_suggested_prompts: Optional[AsyncSetSuggestedPrompts] = None, get_thread_context: Optional[AsyncGetThreadContext] = None, save_thread_context: Optional[AsyncSaveThreadContext] = None, + agent: Optional[AsyncBoltAgent] = None, next: Callable[[], Awaitable[None]], **kwargs, # noqa ): @@ -164,6 +168,7 @@ def __init__( self.set_suggested_prompts = set_suggested_prompts self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context + self.agent = agent self.next: Callable[[], Awaitable[None]] = next self.next_: Callable[[], Awaitable[None]] = next diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index c8870c3cc..733c459a2 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -29,7 +29,7 @@ def build_async_required_kwargs( error: Optional[Exception] = None, # for error handlers next_keys_required: bool = True, # False for listeners / middleware / error handlers ) -> Dict[str, Any]: - all_available_args = { + all_available_args: Dict[str, Any] = { "logger": logger, "client": request.context.client, "req": request, @@ -58,6 +58,7 @@ def build_async_required_kwargs( "set_suggested_prompts": request.context.set_suggested_prompts, "get_thread_context": request.context.get_thread_context, "save_thread_context": request.context.save_thread_context, + "agent": request.context.agent, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function @@ -102,7 +103,7 @@ def build_async_required_kwargs( for name in required_arg_names: if name == "args": if isinstance(request, AsyncBoltRequest): - kwargs[name] = AsyncArgs(**all_available_args) # type: ignore[arg-type] + kwargs[name] = AsyncArgs(**all_available_args) else: logger.warning(f"Unknown Request object type detected ({type(request)})") diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index c1909c67a..cf65c0e96 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -29,7 +29,7 @@ def build_required_kwargs( error: Optional[Exception] = None, # for error handlers next_keys_required: bool = True, # False for listeners / middleware / error handlers ) -> Dict[str, Any]: - all_available_args = { + all_available_args: Dict[str, Any] = { "logger": logger, "client": request.context.client, "req": request, @@ -57,6 +57,7 @@ def build_required_kwargs( "set_title": request.context.set_title, "set_suggested_prompts": request.context.set_suggested_prompts, "save_thread_context": request.context.save_thread_context, + "agent": request.context.agent, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function @@ -101,7 +102,7 @@ def build_required_kwargs( for name in required_arg_names: if name == "args": if isinstance(request, BoltRequest): - kwargs[name] = Args(**all_available_args) # type: ignore[arg-type] + kwargs[name] = Args(**all_available_args) else: logger.warning(f"Unknown Request object type detected ({type(request)})") diff --git a/tests/scenario_tests/test_events_agent.py b/tests/scenario_tests/test_events_agent.py new file mode 100644 index 000000000..7d7d3bd6f --- /dev/null +++ b/tests/scenario_tests/test_events_agent.py @@ -0,0 +1,247 @@ +import json +from time import sleep +from unittest.mock import patch, MagicMock + +from slack_sdk.web import WebClient +from slack_sdk.web.chat_stream import ChatStream + +from slack_bolt import App, BoltRequest, BoltContext, BoltAgent +from slack_bolt.agent.agent import BoltAgent as BoltAgentDirect +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsAgent: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def test_agent_injected_for_app_mention(self): + app = App(client=self.web_client) + + state = {"called": False} + + def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.event("app_mention") + def handle_mention(agent: BoltAgent, context: BoltContext): + assert agent is not None + assert isinstance(agent, BoltAgentDirect) + assert context.channel_id == "C111" + state["called"] = True + + request = BoltRequest(body=app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + + def test_agent_chat_stream_uses_context_defaults(self): + """BoltAgent.chat_stream() passes context defaults to WebClient.chat_stream().""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgentDirect( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = agent.chat_stream() + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + + def test_agent_chat_stream_overrides_context_defaults(self): + """Explicit kwargs to chat_stream() override context defaults.""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgentDirect( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = agent.chat_stream( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + + client.chat_stream.assert_called_once_with( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + assert stream is not None + + def test_agent_chat_stream_passes_extra_kwargs(self): + """Extra kwargs are forwarded to WebClient.chat_stream().""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgentDirect( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + agent.chat_stream(buffer_size=512) + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + buffer_size=512, + ) + + def test_agent_available_in_action_listener(self): + app = App(client=self.web_client) + + state = {"called": False} + + def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.action("test_action") + def handle_action(ack, agent: BoltAgent): + ack() + assert agent is not None + assert isinstance(agent, BoltAgentDirect) + state["called"] = True + + request = BoltRequest(body=json.dumps(action_event_body), mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + + def test_agent_accessible_via_context(self): + app = App(client=self.web_client) + + state = {"called": False} + + def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.event("app_mention") + def handle_mention(context: BoltContext): + agent = context.agent + assert agent is not None + assert isinstance(agent, BoltAgentDirect) + # Verify the same instance is returned on subsequent access + assert context.agent is agent + state["called"] = True + + request = BoltRequest(body=app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + + def test_agent_import_from_slack_bolt(self): + from slack_bolt import BoltAgent as ImportedBoltAgent + + assert ImportedBoltAgent is BoltAgentDirect + + def test_agent_import_from_agent_module(self): + from slack_bolt.agent import BoltAgent as ImportedBoltAgent + + assert ImportedBoltAgent is BoltAgentDirect + + +# ---- Test event bodies ---- + + +def build_payload(event: dict) -> dict: + return { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": event, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + +app_mention_event_body = build_payload( + { + "type": "app_mention", + "user": "W222", + "text": "<@W111> hello", + "ts": "1234567890.123456", + "channel": "C111", + "event_ts": "1234567890.123456", + } +) + +action_event_body = { + "type": "block_actions", + "user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"}, + "api_app_id": "A111", + "token": "verification_token", + "container": {"type": "message", "message_ts": "1234567890.123456", "channel_id": "C111", "is_ephemeral": False}, + "channel": {"id": "C111", "name": "test-channel"}, + "team": {"id": "T111", "domain": "test"}, + "enterprise": {"id": "E111", "name": "test"}, + "trigger_id": "111.222.xxx", + "actions": [ + { + "type": "button", + "block_id": "b", + "action_id": "test_action", + "text": {"type": "plain_text", "text": "Button"}, + "action_ts": "1234567890.123456", + } + ], +} diff --git a/tests/scenario_tests_async/test_events_agent.py b/tests/scenario_tests_async/test_events_agent.py new file mode 100644 index 000000000..8c8c1fce3 --- /dev/null +++ b/tests/scenario_tests_async/test_events_agent.py @@ -0,0 +1,254 @@ +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_chat_stream import AsyncChatStream + +from slack_bolt.agent.async_agent import AsyncBoltAgent +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsAgent: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_agent_injected_for_app_mention(self): + app = AsyncApp(client=self.web_client) + + state = {"called": False} + + async def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.event("app_mention") + async def handle_mention(agent: AsyncBoltAgent, context: AsyncBoltContext): + assert agent is not None + assert isinstance(agent, AsyncBoltAgent) + assert context.channel_id == "C111" + state["called"] = True + + request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + + @pytest.mark.asyncio + async def test_agent_chat_stream_uses_context_defaults(self): + """AsyncBoltAgent.chat_stream() passes context defaults to AsyncWebClient.chat_stream().""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream = AsyncMock(return_value=MagicMock(spec=AsyncChatStream)) + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = await agent.chat_stream() + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + + @pytest.mark.asyncio + async def test_agent_chat_stream_overrides_context_defaults(self): + """Explicit kwargs to chat_stream() override context defaults.""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream = AsyncMock(return_value=MagicMock(spec=AsyncChatStream)) + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = await agent.chat_stream( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + + client.chat_stream.assert_called_once_with( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + assert stream is not None + + @pytest.mark.asyncio + async def test_agent_chat_stream_passes_extra_kwargs(self): + """Extra kwargs are forwarded to AsyncWebClient.chat_stream().""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream = AsyncMock(return_value=MagicMock(spec=AsyncChatStream)) + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + await agent.chat_stream(buffer_size=512) + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + buffer_size=512, + ) + + @pytest.mark.asyncio + async def test_agent_available_in_action_listener(self): + app = AsyncApp(client=self.web_client) + + state = {"called": False} + + async def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.action("test_action") + async def handle_action(ack, agent: AsyncBoltAgent): + await ack() + assert agent is not None + assert isinstance(agent, AsyncBoltAgent) + state["called"] = True + + request = AsyncBoltRequest(body=json.dumps(action_event_body), mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + + @pytest.mark.asyncio + async def test_agent_accessible_via_context(self): + app = AsyncApp(client=self.web_client) + + state = {"called": False} + + async def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.event("app_mention") + async def handle_mention(context: AsyncBoltContext): + agent = context.agent + assert agent is not None + assert isinstance(agent, AsyncBoltAgent) + # Verify the same instance is returned on subsequent access + assert context.agent is agent + state["called"] = True + + request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + + @pytest.mark.asyncio + async def test_agent_import_from_agent_module(self): + from slack_bolt.agent.async_agent import AsyncBoltAgent as ImportedAsyncBoltAgent + + assert ImportedAsyncBoltAgent is AsyncBoltAgent + + +# ---- Test event bodies ---- + + +def build_payload(event: dict) -> dict: + return { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": event, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + +app_mention_event_body = build_payload( + { + "type": "app_mention", + "user": "W222", + "text": "<@W111> hello", + "ts": "1234567890.123456", + "channel": "C111", + "event_ts": "1234567890.123456", + } +) + +action_event_body = { + "type": "block_actions", + "user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"}, + "api_app_id": "A111", + "token": "verification_token", + "container": {"type": "message", "message_ts": "1234567890.123456", "channel_id": "C111", "is_ephemeral": False}, + "channel": {"id": "C111", "name": "test-channel"}, + "team": {"id": "T111", "domain": "test"}, + "enterprise": {"id": "E111", "name": "test"}, + "trigger_id": "111.222.xxx", + "actions": [ + { + "type": "button", + "block_id": "b", + "action_id": "test_action", + "text": {"type": "plain_text", "text": "Button"}, + "action_ts": "1234567890.123456", + } + ], +} From 10c56e9bce0f081012ddc2a6a5769b1cd97bec2d Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 10 Feb 2026 09:49:35 -0800 Subject: [PATCH 02/11] fix: export AsyncBoltAgent from agent module --- slack_bolt/agent/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/slack_bolt/agent/__init__.py b/slack_bolt/agent/__init__.py index 4d83a07ea..a05631f71 100644 --- a/slack_bolt/agent/__init__.py +++ b/slack_bolt/agent/__init__.py @@ -1,5 +1,7 @@ from slack_bolt.agent.agent import BoltAgent +from slack_bolt.agent.async_agent import AsyncBoltAgent __all__ = [ + "AsyncBoltAgent", "BoltAgent", ] From e6e456a88e62e7e9125a2a43fea1e89b5be13235 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 10 Feb 2026 09:52:55 -0800 Subject: [PATCH 03/11] perf: defer BoltAgent construction to when listener requests it --- slack_bolt/kwargs_injection/async_utils.py | 5 ++++- slack_bolt/kwargs_injection/utils.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index 733c459a2..e81cc17e2 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -58,7 +58,6 @@ def build_async_required_kwargs( "set_suggested_prompts": request.context.set_suggested_prompts, "get_thread_context": request.context.get_thread_context, "save_thread_context": request.context.save_thread_context, - "agent": request.context.agent, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function @@ -84,6 +83,10 @@ def build_async_required_kwargs( if k not in all_available_args: all_available_args[k] = v + # Defer agent creation to avoid constructing AsyncBoltAgent on every request + if "agent" in required_arg_names or "args" in required_arg_names: + all_available_args["agent"] = request.context.agent + if len(required_arg_names) > 0: # To support instance/class methods in a class for listeners/middleware, # check if the first argument is either self or cls diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index cf65c0e96..802e124b5 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -57,7 +57,6 @@ def build_required_kwargs( "set_title": request.context.set_title, "set_suggested_prompts": request.context.set_suggested_prompts, "save_thread_context": request.context.save_thread_context, - "agent": request.context.agent, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function @@ -83,6 +82,10 @@ def build_required_kwargs( if k not in all_available_args: all_available_args[k] = v + # Defer agent creation to avoid constructing BoltAgent on every request + if "agent" in required_arg_names or "args" in required_arg_names: + all_available_args["agent"] = request.context.agent + if len(required_arg_names) > 0: # To support instance/class methods in a class for listeners/middleware, # check if the first argument is either self or cls From d0752cb792242b0bd28043b7658e7defc0176620 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 10 Feb 2026 16:24:37 -0800 Subject: [PATCH 04/11] fixme: note chat_stream limitation with channel messages missing ts --- slack_bolt/agent/agent.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/slack_bolt/agent/agent.py b/slack_bolt/agent/agent.py index bbe55ba50..8d4a6fe07 100644 --- a/slack_bolt/agent/agent.py +++ b/slack_bolt/agent/agent.py @@ -10,6 +10,9 @@ class BoltAgent: Experimental: This API is experimental and may change in future releases. + FIXME: chat_stream() only works when thread_ts is available (DMs and threaded replies). + It does not work on channel messages because ts is not provided to BoltAgent yet. + @app.event("app_mention") def handle_mention(agent): stream = agent.chat_stream() From 1a73ac475fa380174f6f11fb6ce18571eb109402 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 10 Feb 2026 20:27:26 -0800 Subject: [PATCH 05/11] fix: don't import AsyncBoltAgent in agent __init__ to avoid aiohttp dependency AsyncBoltAgent imports AsyncWebClient which requires aiohttp. Eagerly importing it from the agent package __init__ breaks environments where aiohttp is not installed, since slack_bolt/__init__.py imports BoltAgent from this package. Follows the existing convention of not adding async module imports at the top level. --- slack_bolt/agent/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/slack_bolt/agent/__init__.py b/slack_bolt/agent/__init__.py index a05631f71..4d83a07ea 100644 --- a/slack_bolt/agent/__init__.py +++ b/slack_bolt/agent/__init__.py @@ -1,7 +1,5 @@ from slack_bolt.agent.agent import BoltAgent -from slack_bolt.agent.async_agent import AsyncBoltAgent __all__ = [ - "AsyncBoltAgent", "BoltAgent", ] From 7722f3edfa10a21dc8af85fb1dbfa10aa0d79645 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 10 Feb 2026 20:38:21 -0800 Subject: [PATCH 06/11] fix: handle AsyncMock import for older Python versions in async agent tests --- tests/scenario_tests_async/test_events_agent.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/scenario_tests_async/test_events_agent.py b/tests/scenario_tests_async/test_events_agent.py index 8c8c1fce3..22a9644cd 100644 --- a/tests/scenario_tests_async/test_events_agent.py +++ b/tests/scenario_tests_async/test_events_agent.py @@ -1,6 +1,9 @@ import asyncio import json -from unittest.mock import AsyncMock, MagicMock +try: + from unittest.mock import AsyncMock, MagicMock +except ImportError: + from mock import AsyncMock, MagicMock # type: ignore import pytest from slack_sdk.web.async_client import AsyncWebClient From c7e0089056dcb3544fa7c8aa723e0cc1c6a26d0a Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 10 Feb 2026 21:33:06 -0800 Subject: [PATCH 07/11] fix: remove AsyncMock dependency from async agent tests for Python 3.7 compat Replace AsyncMock usage with coroutine-returning MagicMock wrappers, matching the pattern used in the sync test suite. This avoids the Python 3.8+ AsyncMock and the need for the mock backport package. --- .../scenario_tests_async/test_events_agent.py | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/scenario_tests_async/test_events_agent.py b/tests/scenario_tests_async/test_events_agent.py index 22a9644cd..23c3e181d 100644 --- a/tests/scenario_tests_async/test_events_agent.py +++ b/tests/scenario_tests_async/test_events_agent.py @@ -1,9 +1,6 @@ import asyncio import json -try: - from unittest.mock import AsyncMock, MagicMock -except ImportError: - from mock import AsyncMock, MagicMock # type: ignore +from unittest.mock import MagicMock import pytest from slack_sdk.web.async_client import AsyncWebClient @@ -20,6 +17,17 @@ from tests.utils import remove_os_env_temporarily, restore_os_env +def _make_async_chat_stream_mock(): + mock_stream = MagicMock(spec=AsyncChatStream) + call_tracker = MagicMock() + + async def fake_chat_stream(**kwargs): + call_tracker(**kwargs) + return mock_stream + + return fake_chat_stream, call_tracker, mock_stream + + class TestAsyncEventsAgent: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" @@ -68,7 +76,7 @@ async def handle_mention(agent: AsyncBoltAgent, context: AsyncBoltContext): async def test_agent_chat_stream_uses_context_defaults(self): """AsyncBoltAgent.chat_stream() passes context defaults to AsyncWebClient.chat_stream().""" client = MagicMock(spec=AsyncWebClient) - client.chat_stream = AsyncMock(return_value=MagicMock(spec=AsyncChatStream)) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() agent = AsyncBoltAgent( client=client, @@ -79,7 +87,7 @@ async def test_agent_chat_stream_uses_context_defaults(self): ) stream = await agent.chat_stream() - client.chat_stream.assert_called_once_with( + call_tracker.assert_called_once_with( channel="C111", thread_ts="1234567890.123456", recipient_team_id="T111", @@ -91,7 +99,7 @@ async def test_agent_chat_stream_uses_context_defaults(self): async def test_agent_chat_stream_overrides_context_defaults(self): """Explicit kwargs to chat_stream() override context defaults.""" client = MagicMock(spec=AsyncWebClient) - client.chat_stream = AsyncMock(return_value=MagicMock(spec=AsyncChatStream)) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() agent = AsyncBoltAgent( client=client, @@ -107,7 +115,7 @@ async def test_agent_chat_stream_overrides_context_defaults(self): recipient_user_id="U999", ) - client.chat_stream.assert_called_once_with( + call_tracker.assert_called_once_with( channel="C999", thread_ts="9999999999.999999", recipient_team_id="T999", @@ -119,7 +127,7 @@ async def test_agent_chat_stream_overrides_context_defaults(self): async def test_agent_chat_stream_passes_extra_kwargs(self): """Extra kwargs are forwarded to AsyncWebClient.chat_stream().""" client = MagicMock(spec=AsyncWebClient) - client.chat_stream = AsyncMock(return_value=MagicMock(spec=AsyncChatStream)) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() agent = AsyncBoltAgent( client=client, @@ -130,7 +138,7 @@ async def test_agent_chat_stream_passes_extra_kwargs(self): ) await agent.chat_stream(buffer_size=512) - client.chat_stream.assert_called_once_with( + call_tracker.assert_called_once_with( channel="C111", thread_ts="1234567890.123456", recipient_team_id="T111", From 5335855b0e72df0f12d6444a7b42528f03abd49d Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Wed, 11 Feb 2026 09:18:06 -0800 Subject: [PATCH 08/11] chore: use relative imports in agent/__init__.py --- slack_bolt/agent/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/agent/__init__.py b/slack_bolt/agent/__init__.py index 4d83a07ea..4d751f27f 100644 --- a/slack_bolt/agent/__init__.py +++ b/slack_bolt/agent/__init__.py @@ -1,4 +1,4 @@ -from slack_bolt.agent.agent import BoltAgent +from .agent import BoltAgent __all__ = [ "BoltAgent", From cf5ef985d890ecf1fd850328256bbdfc5d5cc98e Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Wed, 11 Feb 2026 20:09:02 -0800 Subject: [PATCH 09/11] feat: emit ExperimentalWarning when agent kwarg is used Adds a custom ExperimentalWarning (subclass of FutureWarning) that is emitted when a listener explicitly requests the `agent` argument, informing developers that this feature is experimental and subject to change. Co-Authored-By: William Bergamin --- slack_bolt/adapter/__init__.py | 3 +-- slack_bolt/kwargs_injection/async_utils.py | 8 ++++++ slack_bolt/kwargs_injection/utils.py | 8 ++++++ slack_bolt/warning/__init__.py | 7 ++++++ tests/scenario_tests/test_events_agent.py | 25 +++++++++++++++++++ .../scenario_tests_async/test_events_agent.py | 25 +++++++++++++++++++ 6 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 slack_bolt/warning/__init__.py diff --git a/slack_bolt/adapter/__init__.py b/slack_bolt/adapter/__init__.py index f339226bc..9ca556e52 100644 --- a/slack_bolt/adapter/__init__.py +++ b/slack_bolt/adapter/__init__.py @@ -1,2 +1 @@ -"""Adapter modules for running Bolt apps along with Web frameworks or Socket Mode. -""" +"""Adapter modules for running Bolt apps along with Web frameworks or Socket Mode.""" diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index e81cc17e2..35ffacf45 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -1,9 +1,11 @@ import inspect import logging +import warnings from typing import Callable, Dict, MutableSequence, Optional, Any from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse +from slack_bolt.warning import ExperimentalWarning from .async_args import AsyncArgs from slack_bolt.request.payload_utils import ( to_options, @@ -86,6 +88,12 @@ def build_async_required_kwargs( # Defer agent creation to avoid constructing AsyncBoltAgent on every request if "agent" in required_arg_names or "args" in required_arg_names: all_available_args["agent"] = request.context.agent + if "agent" in required_arg_names: + warnings.warn( + "The agent listener argument is experimental and may change in future versions.", + category=ExperimentalWarning, + stacklevel=2, # Point to the caller, not this internal helper + ) if len(required_arg_names) > 0: # To support instance/class methods in a class for listeners/middleware, diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 802e124b5..8f9fc9886 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -1,9 +1,11 @@ import inspect import logging +import warnings from typing import Callable, Dict, MutableSequence, Optional, Any from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse +from slack_bolt.warning import ExperimentalWarning from .args import Args from slack_bolt.request.payload_utils import ( to_options, @@ -85,6 +87,12 @@ def build_required_kwargs( # Defer agent creation to avoid constructing BoltAgent on every request if "agent" in required_arg_names or "args" in required_arg_names: all_available_args["agent"] = request.context.agent + if "agent" in required_arg_names: + warnings.warn( + "The agent listener argument is experimental and may change in future versions.", + category=ExperimentalWarning, + stacklevel=2, # Point to the caller, not this internal helper + ) if len(required_arg_names) > 0: # To support instance/class methods in a class for listeners/middleware, diff --git a/slack_bolt/warning/__init__.py b/slack_bolt/warning/__init__.py new file mode 100644 index 000000000..4991f4cd9 --- /dev/null +++ b/slack_bolt/warning/__init__.py @@ -0,0 +1,7 @@ +"""Bolt specific warning types.""" + + +class ExperimentalWarning(FutureWarning): + """Warning for features that are still in experimental phase.""" + + pass diff --git a/tests/scenario_tests/test_events_agent.py b/tests/scenario_tests/test_events_agent.py index 7d7d3bd6f..840b5d5b2 100644 --- a/tests/scenario_tests/test_events_agent.py +++ b/tests/scenario_tests/test_events_agent.py @@ -2,11 +2,13 @@ from time import sleep from unittest.mock import patch, MagicMock +import pytest from slack_sdk.web import WebClient from slack_sdk.web.chat_stream import ChatStream from slack_bolt import App, BoltRequest, BoltContext, BoltAgent from slack_bolt.agent.agent import BoltAgent as BoltAgentDirect +from slack_bolt.warning import ExperimentalWarning from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -188,6 +190,29 @@ def test_agent_import_from_agent_module(self): assert ImportedBoltAgent is BoltAgentDirect + def test_agent_kwarg_emits_experimental_warning(self): + app = App(client=self.web_client) + + state = {"called": False} + + def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.event("app_mention") + def handle_mention(agent: BoltAgent): + state["called"] = True + + request = BoltRequest(body=app_mention_event_body, mode="socket_mode") + with pytest.warns(ExperimentalWarning, match="agent listener argument is experimental"): + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + # ---- Test event bodies ---- diff --git a/tests/scenario_tests_async/test_events_agent.py b/tests/scenario_tests_async/test_events_agent.py index 23c3e181d..abc86c842 100644 --- a/tests/scenario_tests_async/test_events_agent.py +++ b/tests/scenario_tests_async/test_events_agent.py @@ -10,6 +10,7 @@ from slack_bolt.app.async_app import AsyncApp from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.warning import ExperimentalWarning from tests.mock_web_api_server import ( cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, @@ -206,6 +207,30 @@ async def test_agent_import_from_agent_module(self): assert ImportedAsyncBoltAgent is AsyncBoltAgent + @pytest.mark.asyncio + async def test_agent_kwarg_emits_experimental_warning(self): + app = AsyncApp(client=self.web_client) + + state = {"called": False} + + async def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.event("app_mention") + async def handle_mention(agent: AsyncBoltAgent): + state["called"] = True + + request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") + with pytest.warns(ExperimentalWarning, match="agent listener argument is experimental"): + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + # ---- Test event bodies ---- From 724ea5ff7940388e8d28c4f13c38f8bdf8dff895 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Wed, 11 Feb 2026 20:48:24 -0800 Subject: [PATCH 10/11] fix: disallow partial overrides of context args in agent chat_stream() --- slack_bolt/agent/agent.py | 5 +++++ slack_bolt/agent/async_agent.py | 5 +++++ tests/scenario_tests/test_events_agent.py | 13 +++++++++++++ tests/scenario_tests_async/test_events_agent.py | 14 ++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/slack_bolt/agent/agent.py b/slack_bolt/agent/agent.py index 8d4a6fe07..b6b3deeeb 100644 --- a/slack_bolt/agent/agent.py +++ b/slack_bolt/agent/agent.py @@ -58,6 +58,11 @@ def chat_stream( Returns: A new ``ChatStream`` instance. """ + provided = [arg for arg in (channel, thread_ts, recipient_team_id, recipient_user_id) if arg is not None] + if provided and len(provided) < 4: + raise ValueError( + "Either provide all of channel, thread_ts, recipient_team_id, and recipient_user_id, or none of them" + ) resolved_channel = channel or self._channel_id resolved_thread_ts = thread_ts or self._thread_ts if resolved_channel is None: diff --git a/slack_bolt/agent/async_agent.py b/slack_bolt/agent/async_agent.py index 7ef173d3e..425f8dff4 100644 --- a/slack_bolt/agent/async_agent.py +++ b/slack_bolt/agent/async_agent.py @@ -55,6 +55,11 @@ async def chat_stream( Returns: A new ``AsyncChatStream`` instance. """ + provided = [arg for arg in (channel, thread_ts, recipient_team_id, recipient_user_id) if arg is not None] + if provided and len(provided) < 4: + raise ValueError( + "Either provide all of channel, thread_ts, recipient_team_id, and recipient_user_id, or none of them" + ) resolved_channel = channel or self._channel_id resolved_thread_ts = thread_ts or self._thread_ts if resolved_channel is None: diff --git a/tests/scenario_tests/test_events_agent.py b/tests/scenario_tests/test_events_agent.py index 840b5d5b2..e78740582 100644 --- a/tests/scenario_tests/test_events_agent.py +++ b/tests/scenario_tests/test_events_agent.py @@ -106,6 +106,19 @@ def test_agent_chat_stream_overrides_context_defaults(self): ) assert stream is not None + def test_agent_chat_stream_rejects_partial_overrides(self): + """Passing only some of the four context args raises ValueError.""" + client = MagicMock(spec=WebClient) + agent = BoltAgentDirect( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + with pytest.raises(ValueError, match="Either provide all of"): + agent.chat_stream(channel="C999") + def test_agent_chat_stream_passes_extra_kwargs(self): """Extra kwargs are forwarded to WebClient.chat_stream().""" client = MagicMock(spec=WebClient) diff --git a/tests/scenario_tests_async/test_events_agent.py b/tests/scenario_tests_async/test_events_agent.py index abc86c842..829c8c619 100644 --- a/tests/scenario_tests_async/test_events_agent.py +++ b/tests/scenario_tests_async/test_events_agent.py @@ -124,6 +124,20 @@ async def test_agent_chat_stream_overrides_context_defaults(self): ) assert stream is not None + @pytest.mark.asyncio + async def test_agent_chat_stream_rejects_partial_overrides(self): + """Passing only some of the four context args raises ValueError.""" + client = MagicMock(spec=AsyncWebClient) + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + with pytest.raises(ValueError, match="Either provide all of"): + await agent.chat_stream(channel="C999") + @pytest.mark.asyncio async def test_agent_chat_stream_passes_extra_kwargs(self): """Extra kwargs are forwarded to AsyncWebClient.chat_stream().""" From 0492aa087796ae3b1c39cb06d6c43d9b25082515 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Wed, 11 Feb 2026 21:03:04 -0800 Subject: [PATCH 11/11] refactor: move agent unit tests into dedicated test directories Split agent tests so unit tests live in tests/slack_bolt/agent/ and tests/slack_bolt_async/agent/, matching the existing convention where test directories mirror the source layout. Integration tests that dispatch through App remain in scenario_tests/. --- tests/scenario_tests/test_events_agent.py | 96 --------------- .../scenario_tests_async/test_events_agent.py | 107 ---------------- tests/slack_bolt/agent/__init__.py | 0 tests/slack_bolt/agent/test_agent.py | 103 ++++++++++++++++ tests/slack_bolt_async/agent/__init__.py | 0 .../agent/test_async_agent.py | 114 ++++++++++++++++++ 6 files changed, 217 insertions(+), 203 deletions(-) create mode 100644 tests/slack_bolt/agent/__init__.py create mode 100644 tests/slack_bolt/agent/test_agent.py create mode 100644 tests/slack_bolt_async/agent/__init__.py create mode 100644 tests/slack_bolt_async/agent/test_async_agent.py diff --git a/tests/scenario_tests/test_events_agent.py b/tests/scenario_tests/test_events_agent.py index e78740582..636ade669 100644 --- a/tests/scenario_tests/test_events_agent.py +++ b/tests/scenario_tests/test_events_agent.py @@ -1,10 +1,8 @@ import json from time import sleep -from unittest.mock import patch, MagicMock import pytest from slack_sdk.web import WebClient -from slack_sdk.web.chat_stream import ChatStream from slack_bolt import App, BoltRequest, BoltContext, BoltAgent from slack_bolt.agent.agent import BoltAgent as BoltAgentDirect @@ -57,90 +55,6 @@ def handle_mention(agent: BoltAgent, context: BoltContext): assert response.status == 200 assert_target_called() - def test_agent_chat_stream_uses_context_defaults(self): - """BoltAgent.chat_stream() passes context defaults to WebClient.chat_stream().""" - client = MagicMock(spec=WebClient) - client.chat_stream.return_value = MagicMock(spec=ChatStream) - - agent = BoltAgentDirect( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - stream = agent.chat_stream() - - client.chat_stream.assert_called_once_with( - channel="C111", - thread_ts="1234567890.123456", - recipient_team_id="T111", - recipient_user_id="W222", - ) - assert stream is not None - - def test_agent_chat_stream_overrides_context_defaults(self): - """Explicit kwargs to chat_stream() override context defaults.""" - client = MagicMock(spec=WebClient) - client.chat_stream.return_value = MagicMock(spec=ChatStream) - - agent = BoltAgentDirect( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - stream = agent.chat_stream( - channel="C999", - thread_ts="9999999999.999999", - recipient_team_id="T999", - recipient_user_id="U999", - ) - - client.chat_stream.assert_called_once_with( - channel="C999", - thread_ts="9999999999.999999", - recipient_team_id="T999", - recipient_user_id="U999", - ) - assert stream is not None - - def test_agent_chat_stream_rejects_partial_overrides(self): - """Passing only some of the four context args raises ValueError.""" - client = MagicMock(spec=WebClient) - agent = BoltAgentDirect( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - with pytest.raises(ValueError, match="Either provide all of"): - agent.chat_stream(channel="C999") - - def test_agent_chat_stream_passes_extra_kwargs(self): - """Extra kwargs are forwarded to WebClient.chat_stream().""" - client = MagicMock(spec=WebClient) - client.chat_stream.return_value = MagicMock(spec=ChatStream) - - agent = BoltAgentDirect( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - agent.chat_stream(buffer_size=512) - - client.chat_stream.assert_called_once_with( - channel="C111", - thread_ts="1234567890.123456", - recipient_team_id="T111", - recipient_user_id="W222", - buffer_size=512, - ) - def test_agent_available_in_action_listener(self): app = App(client=self.web_client) @@ -193,16 +107,6 @@ def handle_mention(context: BoltContext): assert response.status == 200 assert_target_called() - def test_agent_import_from_slack_bolt(self): - from slack_bolt import BoltAgent as ImportedBoltAgent - - assert ImportedBoltAgent is BoltAgentDirect - - def test_agent_import_from_agent_module(self): - from slack_bolt.agent import BoltAgent as ImportedBoltAgent - - assert ImportedBoltAgent is BoltAgentDirect - def test_agent_kwarg_emits_experimental_warning(self): app = App(client=self.web_client) diff --git a/tests/scenario_tests_async/test_events_agent.py b/tests/scenario_tests_async/test_events_agent.py index 829c8c619..a665d786b 100644 --- a/tests/scenario_tests_async/test_events_agent.py +++ b/tests/scenario_tests_async/test_events_agent.py @@ -1,10 +1,8 @@ import asyncio import json -from unittest.mock import MagicMock import pytest from slack_sdk.web.async_client import AsyncWebClient -from slack_sdk.web.async_chat_stream import AsyncChatStream from slack_bolt.agent.async_agent import AsyncBoltAgent from slack_bolt.app.async_app import AsyncApp @@ -18,17 +16,6 @@ from tests.utils import remove_os_env_temporarily, restore_os_env -def _make_async_chat_stream_mock(): - mock_stream = MagicMock(spec=AsyncChatStream) - call_tracker = MagicMock() - - async def fake_chat_stream(**kwargs): - call_tracker(**kwargs) - return mock_stream - - return fake_chat_stream, call_tracker, mock_stream - - class TestAsyncEventsAgent: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" @@ -73,94 +60,6 @@ async def handle_mention(agent: AsyncBoltAgent, context: AsyncBoltContext): assert response.status == 200 await assert_target_called() - @pytest.mark.asyncio - async def test_agent_chat_stream_uses_context_defaults(self): - """AsyncBoltAgent.chat_stream() passes context defaults to AsyncWebClient.chat_stream().""" - client = MagicMock(spec=AsyncWebClient) - client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - stream = await agent.chat_stream() - - call_tracker.assert_called_once_with( - channel="C111", - thread_ts="1234567890.123456", - recipient_team_id="T111", - recipient_user_id="W222", - ) - assert stream is not None - - @pytest.mark.asyncio - async def test_agent_chat_stream_overrides_context_defaults(self): - """Explicit kwargs to chat_stream() override context defaults.""" - client = MagicMock(spec=AsyncWebClient) - client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - stream = await agent.chat_stream( - channel="C999", - thread_ts="9999999999.999999", - recipient_team_id="T999", - recipient_user_id="U999", - ) - - call_tracker.assert_called_once_with( - channel="C999", - thread_ts="9999999999.999999", - recipient_team_id="T999", - recipient_user_id="U999", - ) - assert stream is not None - - @pytest.mark.asyncio - async def test_agent_chat_stream_rejects_partial_overrides(self): - """Passing only some of the four context args raises ValueError.""" - client = MagicMock(spec=AsyncWebClient) - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - with pytest.raises(ValueError, match="Either provide all of"): - await agent.chat_stream(channel="C999") - - @pytest.mark.asyncio - async def test_agent_chat_stream_passes_extra_kwargs(self): - """Extra kwargs are forwarded to AsyncWebClient.chat_stream().""" - client = MagicMock(spec=AsyncWebClient) - client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - await agent.chat_stream(buffer_size=512) - - call_tracker.assert_called_once_with( - channel="C111", - thread_ts="1234567890.123456", - recipient_team_id="T111", - recipient_user_id="W222", - buffer_size=512, - ) - @pytest.mark.asyncio async def test_agent_available_in_action_listener(self): app = AsyncApp(client=self.web_client) @@ -215,12 +114,6 @@ async def handle_mention(context: AsyncBoltContext): assert response.status == 200 await assert_target_called() - @pytest.mark.asyncio - async def test_agent_import_from_agent_module(self): - from slack_bolt.agent.async_agent import AsyncBoltAgent as ImportedAsyncBoltAgent - - assert ImportedAsyncBoltAgent is AsyncBoltAgent - @pytest.mark.asyncio async def test_agent_kwarg_emits_experimental_warning(self): app = AsyncApp(client=self.web_client) diff --git a/tests/slack_bolt/agent/__init__.py b/tests/slack_bolt/agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/agent/test_agent.py b/tests/slack_bolt/agent/test_agent.py new file mode 100644 index 000000000..00e998379 --- /dev/null +++ b/tests/slack_bolt/agent/test_agent.py @@ -0,0 +1,103 @@ +from unittest.mock import MagicMock + +import pytest +from slack_sdk.web import WebClient +from slack_sdk.web.chat_stream import ChatStream + +from slack_bolt.agent.agent import BoltAgent + + +class TestBoltAgent: + def test_chat_stream_uses_context_defaults(self): + """BoltAgent.chat_stream() passes context defaults to WebClient.chat_stream().""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = agent.chat_stream() + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + + def test_chat_stream_overrides_context_defaults(self): + """Explicit kwargs to chat_stream() override context defaults.""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = agent.chat_stream( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + + client.chat_stream.assert_called_once_with( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + assert stream is not None + + def test_chat_stream_rejects_partial_overrides(self): + """Passing only some of the four context args raises ValueError.""" + client = MagicMock(spec=WebClient) + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + with pytest.raises(ValueError, match="Either provide all of"): + agent.chat_stream(channel="C999") + + def test_chat_stream_passes_extra_kwargs(self): + """Extra kwargs are forwarded to WebClient.chat_stream().""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + agent.chat_stream(buffer_size=512) + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + buffer_size=512, + ) + + def test_import_from_slack_bolt(self): + from slack_bolt import BoltAgent as ImportedBoltAgent + + assert ImportedBoltAgent is BoltAgent + + def test_import_from_agent_module(self): + from slack_bolt.agent import BoltAgent as ImportedBoltAgent + + assert ImportedBoltAgent is BoltAgent diff --git a/tests/slack_bolt_async/agent/__init__.py b/tests/slack_bolt_async/agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/agent/test_async_agent.py b/tests/slack_bolt_async/agent/test_async_agent.py new file mode 100644 index 000000000..02251fa4b --- /dev/null +++ b/tests/slack_bolt_async/agent/test_async_agent.py @@ -0,0 +1,114 @@ +from unittest.mock import MagicMock + +import pytest +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_chat_stream import AsyncChatStream + +from slack_bolt.agent.async_agent import AsyncBoltAgent + + +def _make_async_chat_stream_mock(): + mock_stream = MagicMock(spec=AsyncChatStream) + call_tracker = MagicMock() + + async def fake_chat_stream(**kwargs): + call_tracker(**kwargs) + return mock_stream + + return fake_chat_stream, call_tracker, mock_stream + + +class TestAsyncBoltAgent: + @pytest.mark.asyncio + async def test_chat_stream_uses_context_defaults(self): + """AsyncBoltAgent.chat_stream() passes context defaults to AsyncWebClient.chat_stream().""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = await agent.chat_stream() + + call_tracker.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + + @pytest.mark.asyncio + async def test_chat_stream_overrides_context_defaults(self): + """Explicit kwargs to chat_stream() override context defaults.""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = await agent.chat_stream( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + + call_tracker.assert_called_once_with( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + assert stream is not None + + @pytest.mark.asyncio + async def test_chat_stream_rejects_partial_overrides(self): + """Passing only some of the four context args raises ValueError.""" + client = MagicMock(spec=AsyncWebClient) + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + with pytest.raises(ValueError, match="Either provide all of"): + await agent.chat_stream(channel="C999") + + @pytest.mark.asyncio + async def test_chat_stream_passes_extra_kwargs(self): + """Extra kwargs are forwarded to AsyncWebClient.chat_stream().""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + await agent.chat_stream(buffer_size=512) + + call_tracker.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + buffer_size=512, + ) + + @pytest.mark.asyncio + async def test_import_from_agent_module(self): + from slack_bolt.agent.async_agent import AsyncBoltAgent as ImportedAsyncBoltAgent + + assert ImportedAsyncBoltAgent is AsyncBoltAgent