From fadc1a4f183e2569406777e6fcfa854d49478750 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Mon, 9 Feb 2026 21:21:26 -0800 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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 From 3a85cd594ae74a2ae7c38723f4db8ced5a01f508 Mon Sep 17 00:00:00 2001 From: Luke Russell Date: Thu, 12 Feb 2026 11:22:04 -0800 Subject: [PATCH 12/13] adds experiments page --- docs/english/experiments.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/english/experiments.md diff --git a/docs/english/experiments.md b/docs/english/experiments.md new file mode 100644 index 000000000..681c8cbc6 --- /dev/null +++ b/docs/english/experiments.md @@ -0,0 +1,34 @@ +# Experiments + +Bolt for Python includes experimental features still under active development. These features may be fleeting, may not be perfectly polished, and should be thought of as available for use "at your own risk." + +Experimental features are categorized as `semver:patch` until the experimental status is removed. + +We love feedback from our community, so we encourage you to explore and interact with the [GitHub repo](https://github.com/slackapi/bolt-python). Contributions, bug reports, and any feedback are all helpful; let us nurture the Slack CLI together to help make building Slack apps more pleasant for everyone. + +## Available experiments +* [Agent listener argument](#agent) + +## Agent listener argument {#agent} + +The `agent: BoltAgent` listener argument provides access to AI agent-related features. + +The `BoltAgent` and `AsyncBoltAgent` classes offer a `chat_stream()` method that comes pre-configured with event context defaults: `channel_id`, `thread_ts`, `team_id`, and `user_id` fields. + +The listener argument is wired into the Bolt `kwargs` injection system, so listeners can declare it as a parameter or access it via the `context.agent` property. + +### Example + +```python +from slack_bolt import BoltAgent + +@app.event("app_mention") +def handle_mention(agent: BoltAgent): + stream = agent.chat_stream() + stream.append(markdown_text="Hello!") + stream.stop() +``` + +### Limitations + +The `chat_stream()` method currently only works when the `thread_ts` field is available in the event context (DMs and threaded replies). Top-level channel messages do not have a `thread_ts` field, and the `ts` field is not yet provided to `BoltAgent`. \ No newline at end of file From f32af7106e8c605a2a56c7e2a1e08e0200754136 Mon Sep 17 00:00:00 2001 From: Luke Russell Date: Thu, 12 Feb 2026 11:24:29 -0800 Subject: [PATCH 13/13] adds experiments to sidebar --- docs/english/_sidebar.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/english/_sidebar.json b/docs/english/_sidebar.json index 859c4b52f..eab9d94f8 100644 --- a/docs/english/_sidebar.json +++ b/docs/english/_sidebar.json @@ -85,6 +85,11 @@ "tools/bolt-python/concepts/token-rotation" ] }, + { + "type": "category", + "label": "Experiments", + "items": ["tools/bolt-python/experiments"] + }, { "type": "category", "label": "Legacy",