From cacf2ad51b1fe333484788600f3fee138bcf3ffc Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 17 Mar 2026 17:27:49 -0400 Subject: [PATCH 01/12] feat: add support for say_stream utility function --- slack_bolt/context/async_context.py | 5 + slack_bolt/context/context.py | 5 + slack_bolt/context/say_stream/__init__.py | 6 + .../context/say_stream/async_say_stream.py | 71 +++++++ slack_bolt/context/say_stream/say_stream.py | 71 +++++++ slack_bolt/kwargs_injection/args.py | 5 + slack_bolt/kwargs_injection/async_args.py | 5 + .../async_attaching_agent_kwargs.py | 12 ++ .../attaching_agent_kwargs.py | 12 ++ .../scenario_tests/test_events_say_stream.py | 171 +++++++++++++++++ .../test_events_say_stream.py | 181 ++++++++++++++++++ 11 files changed, 544 insertions(+) create mode 100644 slack_bolt/context/say_stream/__init__.py create mode 100644 slack_bolt/context/say_stream/async_say_stream.py create mode 100644 slack_bolt/context/say_stream/say_stream.py create mode 100644 tests/scenario_tests/test_events_say_stream.py create mode 100644 tests/scenario_tests_async/test_events_say_stream.py diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 631f74a82..33f260d38 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -10,6 +10,7 @@ from slack_bolt.context.get_thread_context.async_get_thread_context import AsyncGetThreadContext from slack_bolt.context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream from slack_bolt.context.set_status.async_set_status import AsyncSetStatus from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from slack_bolt.context.set_title.async_set_title import AsyncSetTitle @@ -203,6 +204,10 @@ def set_suggested_prompts(self) -> Optional[AsyncSetSuggestedPrompts]: def get_thread_context(self) -> Optional[AsyncGetThreadContext]: return self.get("get_thread_context") + @property + def say_stream(self) -> Optional[AsyncSayStream]: + return self.get("say_stream") + @property def save_thread_context(self) -> Optional[AsyncSaveThreadContext]: return self.get("save_thread_context") diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index 48df4ad32..6184d5083 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -10,6 +10,7 @@ from slack_bolt.context.respond import Respond from slack_bolt.context.save_thread_context import SaveThreadContext from slack_bolt.context.say import Say +from slack_bolt.context.say_stream import SayStream from slack_bolt.context.set_status import SetStatus from slack_bolt.context.set_suggested_prompts import SetSuggestedPrompts from slack_bolt.context.set_title import SetTitle @@ -204,6 +205,10 @@ def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]: def get_thread_context(self) -> Optional[GetThreadContext]: return self.get("get_thread_context") + @property + def say_stream(self) -> Optional[SayStream]: + return self.get("say_stream") + @property def save_thread_context(self) -> Optional[SaveThreadContext]: return self.get("save_thread_context") diff --git a/slack_bolt/context/say_stream/__init__.py b/slack_bolt/context/say_stream/__init__.py new file mode 100644 index 000000000..86db7b1cc --- /dev/null +++ b/slack_bolt/context/say_stream/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .say_stream import SayStream + +__all__ = [ + "SayStream", +] diff --git a/slack_bolt/context/say_stream/async_say_stream.py b/slack_bolt/context/say_stream/async_say_stream.py new file mode 100644 index 000000000..093f78860 --- /dev/null +++ b/slack_bolt/context/say_stream/async_say_stream.py @@ -0,0 +1,71 @@ +import warnings +from typing import Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_chat_stream import AsyncChatStream + +from slack_bolt.warning import ExperimentalWarning + + +class AsyncSayStream: + client: AsyncWebClient + channel_id: Optional[str] + thread_ts: Optional[str] + team_id: Optional[str] + user_id: Optional[str] + + 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 __call__( + self, + *, + buffer_size: Optional[int] = None, + channel: Optional[str] = None, + thread_ts: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, + **kwargs, + ) -> AsyncChatStream: + warnings.warn( + "say_stream is experimental and may change in future versions.", + category=ExperimentalWarning, + stacklevel=2, + ) + + channel = channel or self.channel_id + thread_ts = thread_ts or self.thread_ts + if channel is None: + raise ValueError("say_stream without channel here is unsupported") + if thread_ts is None: + raise ValueError("say_stream without thread_ts here is unsupported") + + if buffer_size: + return await self.client.chat_stream( + buffer_size=buffer_size, + channel=channel, + thread_ts=thread_ts, + recipient_team_id=recipient_team_id or self.team_id, + recipient_user_id=recipient_user_id or self.user_id, + **kwargs, + ) + + return await self.client.chat_stream( + channel=channel, + thread_ts=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/say_stream/say_stream.py b/slack_bolt/context/say_stream/say_stream.py new file mode 100644 index 000000000..9b574c5f8 --- /dev/null +++ b/slack_bolt/context/say_stream/say_stream.py @@ -0,0 +1,71 @@ +import warnings +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.web.chat_stream import ChatStream + +from slack_bolt.warning import ExperimentalWarning + + +class SayStream: + client: WebClient + channel_id: Optional[str] + thread_ts: Optional[str] + team_id: Optional[str] + user_id: Optional[str] + + 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 __call__( + self, + *, + buffer_size: Optional[int] = None, + channel: Optional[str] = None, + thread_ts: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, + **kwargs, + ) -> ChatStream: + warnings.warn( + "say_stream is experimental and may change in future versions.", + category=ExperimentalWarning, + stacklevel=2, + ) + + channel = channel or self.channel_id + thread_ts = thread_ts or self.thread_ts + if channel is None: + raise ValueError("say_stream without channel here is unsupported") + if thread_ts is None: + raise ValueError("say_stream without thread_ts here is unsupported") + + if buffer_size: + return self.client.chat_stream( + buffer_size=buffer_size, + channel=channel, + thread_ts=thread_ts, + recipient_team_id=recipient_team_id or self.team_id, + recipient_user_id=recipient_user_id or self.user_id, + **kwargs, + ) + + return self.client.chat_stream( + channel=channel, + thread_ts=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/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 113e39c08..dfb242fd1 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -11,6 +11,7 @@ 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.say_stream import SayStream from slack_bolt.context.set_status import SetStatus from slack_bolt.context.set_suggested_prompts import SetSuggestedPrompts from slack_bolt.context.set_title import SetTitle @@ -105,6 +106,8 @@ def handle_buttons(args): """`save_thread_context()` utility function for AI Agents & Assistants""" agent: Optional[BoltAgent] """`agent` listener argument for AI Agents & Assistants""" + say_stream: Optional[SayStream] + """`say_stream()` utility function for AI Agents & Assistants""" # middleware next: Callable[[], None] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -139,6 +142,7 @@ def __init__( get_thread_context: Optional[GetThreadContext] = None, save_thread_context: Optional[SaveThreadContext] = None, agent: Optional[BoltAgent] = None, + say_stream: Optional[SayStream] = 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 @@ -173,6 +177,7 @@ def __init__( self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context self.agent = agent + self.say_stream = say_stream 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 1f1dde024..19719e900 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -10,6 +10,7 @@ from slack_bolt.context.get_thread_context.async_get_thread_context import AsyncGetThreadContext from slack_bolt.context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream from slack_bolt.context.set_status.async_set_status import AsyncSetStatus from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from slack_bolt.context.set_title.async_set_title import AsyncSetTitle @@ -104,6 +105,8 @@ async def handle_buttons(args): """`save_thread_context()` utility function for AI Agents & Assistants""" agent: Optional[AsyncBoltAgent] """`agent` listener argument for AI Agents & Assistants""" + say_stream: Optional[AsyncSayStream] + """`say_stream()` utility function 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""" @@ -138,6 +141,7 @@ def __init__( get_thread_context: Optional[AsyncGetThreadContext] = None, save_thread_context: Optional[AsyncSaveThreadContext] = None, agent: Optional[AsyncBoltAgent] = None, + say_stream: Optional[AsyncSayStream] = None, next: Callable[[], Awaitable[None]], **kwargs, # noqa ): @@ -169,6 +173,7 @@ def __init__( self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context self.agent = agent + self.say_stream = say_stream self.next: Callable[[], Awaitable[None]] = next self.next_: Callable[[], Awaitable[None]] = next diff --git a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py index 0b43c21ce..11548c4a8 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py @@ -2,6 +2,7 @@ from slack_bolt.context.assistant.async_assistant_utilities import AsyncAssistantUtilities from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore +from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.request.payload_utils import is_assistant_event, to_event @@ -36,4 +37,15 @@ async def async_process( req.context["set_suggested_prompts"] = assistant.set_suggested_prompts req.context["get_thread_context"] = assistant.get_thread_context req.context["save_thread_context"] = assistant.save_thread_context + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts = req.context.thread_ts or event.get("ts") + if req.context.channel_id and thread_ts: + req.context["say_stream"] = AsyncSayStream( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) return await next() diff --git a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py index 4963ea67d..a8e7862a6 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py @@ -2,6 +2,7 @@ from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore +from slack_bolt.context.say_stream.say_stream import SayStream from slack_bolt.middleware import Middleware from slack_bolt.request.payload_utils import is_assistant_event, to_event from slack_bolt.request.request import BoltRequest @@ -30,4 +31,15 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo req.context["set_suggested_prompts"] = assistant.set_suggested_prompts req.context["get_thread_context"] = assistant.get_thread_context req.context["save_thread_context"] = assistant.save_thread_context + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts = req.context.thread_ts or event.get("ts") + if req.context.channel_id and thread_ts: + req.context["say_stream"] = SayStream( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) return next() diff --git a/tests/scenario_tests/test_events_say_stream.py b/tests/scenario_tests/test_events_say_stream.py new file mode 100644 index 000000000..aaa376016 --- /dev/null +++ b/tests/scenario_tests/test_events_say_stream.py @@ -0,0 +1,171 @@ +import time + +import pytest +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest, BoltContext +from slack_bolt.context.say_stream.say_stream import SayStream +from slack_bolt.middleware.assistant import Assistant +from slack_bolt.warning import ExperimentalWarning +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.scenario_tests.test_app import app_mention_event_body +from tests.scenario_tests.test_events_assistant import ( + thread_started_event_body, + user_message_event_body as threaded_user_message_event_body, +) +from tests.scenario_tests.test_message_bot import bot_message_event_payload, user_message_event_payload +from tests.utils import remove_os_env_temporarily, restore_os_env + + +def assert_target_called(called: dict, timeout: float = 1.0): + deadline = time.time() + timeout + while called["value"] is not True and time.time() < deadline: + time.sleep(0.1) + assert called["value"] is True + + +class TestEventsSayStream: + 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_say_stream_injected_for_app_mention(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("app_mention") + def handle_mention(say_stream: SayStream, context: BoltContext): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream.channel_id == "C111" + assert say_stream.thread_ts == "1595926230.009600" + assert say_stream.team_id == context.team_id + assert say_stream.user_id == context.user_id + called["value"] = True + + request = BoltRequest(body=app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_injected_for_threaded_message(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("message") + def handle_message(say_stream: SayStream, context: BoltContext): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream.channel_id == "D111" + assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.team_id == context.team_id + assert say_stream.user_id == context.user_id + called["value"] = True + + request = BoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_in_user_message(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.message("") + def handle_user_message(say_stream: SayStream): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream.channel_id == "C111" + assert say_stream.thread_ts == "1610261659.001400" + called["value"] = True + + request = BoltRequest(body=user_message_event_payload, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_in_bot_message(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.message("") + def handle_user_message(say_stream: SayStream): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream.channel_id == "C111" + assert say_stream.thread_ts == "1610261539.000900" + called["value"] = True + + request = BoltRequest(body=bot_message_event_payload, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_kwarg_emits_experimental_warning(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("app_mention") + def handle_mention(say_stream: SayStream): + with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): + say_stream() + called["value"] = True + + request = BoltRequest(body=app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_in_assistant_thread_started(self): + app = App(client=self.web_client) + assistant = Assistant() + called = {"value": False} + + @assistant.thread_started + def start_thread(say_stream: SayStream): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream.channel_id == "D111" + assert say_stream.thread_ts == "1726133698.626339" + called["value"] = True + + app.assistant(assistant) + + request = BoltRequest(body=thread_started_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_in_assistant_user_message(self): + app = App(client=self.web_client) + assistant = Assistant() + called = {"value": False} + + @assistant.user_message + def handle_user_message(say_stream: SayStream): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream.channel_id == "D111" + assert say_stream.thread_ts == "1726133698.626339" + called["value"] = True + + app.assistant(assistant) + + request = BoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) diff --git a/tests/scenario_tests_async/test_events_say_stream.py b/tests/scenario_tests_async/test_events_say_stream.py new file mode 100644 index 000000000..dcb3f18f6 --- /dev/null +++ b/tests/scenario_tests_async/test_events_say_stream.py @@ -0,0 +1,181 @@ +import asyncio +import time + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.async_app import AsyncAssistant +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream +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, +) +from tests.scenario_tests_async.test_app import app_mention_event_body +from tests.scenario_tests_async.test_events_assistant import user_message_event_body as threaded_user_message_event_body +from tests.scenario_tests_async.test_events_assistant import thread_started_event_body, user_message_event_body +from tests.scenario_tests_async.test_message_bot import bot_message_event_payload, user_message_event_payload +from tests.utils import remove_os_env_temporarily, restore_os_env + + +async def assert_target_called(called: dict, timeout: float = 0.5): + deadline = time.time() + timeout + while called["value"] is not True and time.time() < deadline: + await asyncio.sleep(0.1) + assert called["value"] is True + + +class TestAsyncEventsSayStream: + 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_say_stream_injected_for_app_mention(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("app_mention") + async def handle_mention(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream.channel_id == "C111" + assert say_stream.thread_ts == "1595926230.009600" + assert say_stream.team_id == context.team_id + assert say_stream.user_id == context.user_id + called["value"] = 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(called) + + @pytest.mark.asyncio + async def test_say_stream_injected_for_threaded_message(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("message") + async def handle_message(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream.channel_id == "D111" + assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.team_id == context.team_id + assert say_stream.user_id == context.user_id + called["value"] = True + + request = AsyncBoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_in_user_message(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.message("") + async def handle_user_message(say_stream: AsyncSayStream): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream.channel_id == "C111" + assert say_stream.thread_ts == "1610261659.001400" + called["value"] = True + + request = AsyncBoltRequest(body=user_message_event_payload, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_in_bot_message(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.message("") + async def handle_user_message(say_stream: AsyncSayStream): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream.channel_id == "C111" + assert say_stream.thread_ts == "1610261539.000900" + called["value"] = True + + request = AsyncBoltRequest(body=bot_message_event_payload, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_kwarg_emits_experimental_warning(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("app_mention") + async def handle_mention(say_stream: AsyncSayStream): + with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): + await say_stream() + called["value"] = 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(called) + + @pytest.mark.asyncio + async def test_say_stream_in_assistant_thread_started(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + called = {"value": False} + + @assistant.thread_started + async def start_thread(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream.channel_id == "D111" + assert say_stream.thread_ts == "1726133698.626339" + called["value"] = True + + app.assistant(assistant) + + request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_in_assistant_user_message(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + called = {"value": False} + + @assistant.user_message + async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream.channel_id == "D111" + assert say_stream.thread_ts == "1726133698.626339" + called["value"] = True + + app.assistant(assistant) + + request = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) From f30dd712dfce9cdd20609c017c3a6a09b2b7ee96 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 17 Mar 2026 17:46:51 -0400 Subject: [PATCH 02/12] Improve unit tests around SayStream --- slack_bolt/__init__.py | 2 + .../context/say_stream/async_say_stream.py | 11 --- slack_bolt/context/say_stream/say_stream.py | 11 --- tests/slack_bolt/context/test_say_stream.py | 84 +++++++++++++++++ .../context/test_async_say_stream.py | 90 +++++++++++++++++++ 5 files changed, 176 insertions(+), 22 deletions(-) create mode 100644 tests/slack_bolt/context/test_say_stream.py create mode 100644 tests/slack_bolt_async/context/test_async_say_stream.py diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index 4e43252fd..dfe950bf2 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -14,6 +14,7 @@ from .context.fail import Fail from .context.respond import Respond from .context.say import Say +from .context.say_stream import SayStream from .kwargs_injection import Args from .listener import Listener from .listener_matcher import CustomListenerMatcher @@ -42,6 +43,7 @@ "Fail", "Respond", "Say", + "SayStream", "Args", "Listener", "CustomListenerMatcher", diff --git a/slack_bolt/context/say_stream/async_say_stream.py b/slack_bolt/context/say_stream/async_say_stream.py index 093f78860..df6336520 100644 --- a/slack_bolt/context/say_stream/async_say_stream.py +++ b/slack_bolt/context/say_stream/async_say_stream.py @@ -32,7 +32,6 @@ def __init__( async def __call__( self, *, - buffer_size: Optional[int] = None, channel: Optional[str] = None, thread_ts: Optional[str] = None, recipient_team_id: Optional[str] = None, @@ -52,16 +51,6 @@ async def __call__( if thread_ts is None: raise ValueError("say_stream without thread_ts here is unsupported") - if buffer_size: - return await self.client.chat_stream( - buffer_size=buffer_size, - channel=channel, - thread_ts=thread_ts, - recipient_team_id=recipient_team_id or self.team_id, - recipient_user_id=recipient_user_id or self.user_id, - **kwargs, - ) - return await self.client.chat_stream( channel=channel, thread_ts=thread_ts, diff --git a/slack_bolt/context/say_stream/say_stream.py b/slack_bolt/context/say_stream/say_stream.py index 9b574c5f8..91e42b9a1 100644 --- a/slack_bolt/context/say_stream/say_stream.py +++ b/slack_bolt/context/say_stream/say_stream.py @@ -32,7 +32,6 @@ def __init__( def __call__( self, *, - buffer_size: Optional[int] = None, channel: Optional[str] = None, thread_ts: Optional[str] = None, recipient_team_id: Optional[str] = None, @@ -52,16 +51,6 @@ def __call__( if thread_ts is None: raise ValueError("say_stream without thread_ts here is unsupported") - if buffer_size: - return self.client.chat_stream( - buffer_size=buffer_size, - channel=channel, - thread_ts=thread_ts, - recipient_team_id=recipient_team_id or self.team_id, - recipient_user_id=recipient_user_id or self.user_id, - **kwargs, - ) - return self.client.chat_stream( channel=channel, thread_ts=thread_ts, diff --git a/tests/slack_bolt/context/test_say_stream.py b/tests/slack_bolt/context/test_say_stream.py new file mode 100644 index 000000000..a38c6c5ae --- /dev/null +++ b/tests/slack_bolt/context/test_say_stream.py @@ -0,0 +1,84 @@ +from unittest.mock import MagicMock + +import pytest +from slack_sdk import WebClient + +from slack_bolt.context.say_stream.say_stream import SayStream +from slack_bolt.warning import ExperimentalWarning + + +class TestSayStream: + def setup_method(self): + self.mock_client = MagicMock(spec=WebClient) + self.mock_client.chat_stream = MagicMock() + + def test_missing_channel_raises(self): + say_stream = SayStream(client=self.mock_client, channel_id=None, thread_ts="111.222") + with pytest.warns(ExperimentalWarning): + with pytest.raises(ValueError, match="channel"): + say_stream() + + def test_missing_thread_ts_raises(self): + say_stream = SayStream(client=self.mock_client, channel_id="C111", thread_ts=None) + with pytest.warns(ExperimentalWarning): + with pytest.raises(ValueError, match="thread_ts"): + say_stream() + + def test_default_params(self): + say_stream = SayStream( + client=self.mock_client, + channel_id="C111", + thread_ts="111.222", + team_id="T111", + user_id="U111", + ) + say_stream() + + self.mock_client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="111.222", + recipient_team_id="T111", + recipient_user_id="U111", + ) + + def test_parameter_overrides(self): + say_stream = SayStream( + client=self.mock_client, + channel_id="C111", + thread_ts="111.222", + team_id="T111", + user_id="U111", + ) + say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + + self.mock_client.chat_stream.assert_called_once_with( + channel="C222", + thread_ts="333.444", + recipient_team_id="T222", + recipient_user_id="U222", + ) + + def test_buffer_size_passthrough(self): + say_stream = SayStream( + client=self.mock_client, + channel_id="C111", + thread_ts="111.222", + ) + say_stream(buffer_size=100) + + self.mock_client.chat_stream.assert_called_once_with( + buffer_size=100, + channel="C111", + thread_ts="111.222", + recipient_team_id=None, + recipient_user_id=None, + ) + + def test_experimental_warning(self): + say_stream = SayStream( + client=self.mock_client, + channel_id="C111", + thread_ts="111.222", + ) + with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): + say_stream() diff --git a/tests/slack_bolt_async/context/test_async_say_stream.py b/tests/slack_bolt_async/context/test_async_say_stream.py new file mode 100644 index 000000000..ba2285f32 --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_say_stream.py @@ -0,0 +1,90 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream +from slack_bolt.warning import ExperimentalWarning + + +class TestAsyncSayStream: + def setup_method(self): + self.mock_client = MagicMock(spec=AsyncWebClient) + self.mock_client.chat_stream = AsyncMock() + + @pytest.mark.asyncio + async def test_missing_channel_raises(self): + say_stream = AsyncSayStream(client=self.mock_client, channel_id=None, thread_ts="111.222") + with pytest.warns(ExperimentalWarning): + with pytest.raises(ValueError, match="channel"): + await say_stream() + + @pytest.mark.asyncio + async def test_missing_thread_ts_raises(self): + say_stream = AsyncSayStream(client=self.mock_client, channel_id="C111", thread_ts=None) + with pytest.warns(ExperimentalWarning): + with pytest.raises(ValueError, match="thread_ts"): + await say_stream() + + @pytest.mark.asyncio + async def test_default_params(self): + say_stream = AsyncSayStream( + client=self.mock_client, + channel_id="C111", + thread_ts="111.222", + team_id="T111", + user_id="U111", + ) + await say_stream() + + self.mock_client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="111.222", + recipient_team_id="T111", + recipient_user_id="U111", + ) + + @pytest.mark.asyncio + async def test_parameter_overrides(self): + say_stream = AsyncSayStream( + client=self.mock_client, + channel_id="C111", + thread_ts="111.222", + team_id="T111", + user_id="U111", + ) + await say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + + self.mock_client.chat_stream.assert_called_once_with( + channel="C222", + thread_ts="333.444", + recipient_team_id="T222", + recipient_user_id="U222", + ) + + @pytest.mark.asyncio + async def test_buffer_size_passthrough(self): + say_stream = AsyncSayStream( + client=self.mock_client, + channel_id="C111", + thread_ts="111.222", + ) + await say_stream(buffer_size=100) + + self.mock_client.chat_stream.assert_called_once_with( + buffer_size=100, + channel="C111", + thread_ts="111.222", + recipient_team_id=None, + recipient_user_id=None, + ) + + @pytest.mark.asyncio + async def test_experimental_warning(self): + say_stream = AsyncSayStream( + client=self.mock_client, + channel_id="C111", + thread_ts="111.222", + ) + with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): + await say_stream() From 3e4946168c6b528e99ce203a69dda6173670701d Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 17 Mar 2026 18:03:08 -0400 Subject: [PATCH 03/12] Patch fixes and better test coverage --- slack_bolt/kwargs_injection/async_utils.py | 1 + slack_bolt/kwargs_injection/utils.py | 1 + .../scenario_tests/test_events_say_stream.py | 21 ++++++++++++++++++ .../test_events_say_stream.py | 22 +++++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index aa84b2d11..534fb6133 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -60,6 +60,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, + "say_stream": request.context.say_stream, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 5cd410a07..101e00099 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -59,6 +59,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, + "say_stream": request.context.say_stream, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function diff --git a/tests/scenario_tests/test_events_say_stream.py b/tests/scenario_tests/test_events_say_stream.py index aaa376016..b58cce817 100644 --- a/tests/scenario_tests/test_events_say_stream.py +++ b/tests/scenario_tests/test_events_say_stream.py @@ -1,4 +1,6 @@ +import json import time +from urllib.parse import quote import pytest from slack_sdk.web import WebClient @@ -17,6 +19,7 @@ user_message_event_body as threaded_user_message_event_body, ) from tests.scenario_tests.test_message_bot import bot_message_event_payload, user_message_event_payload +from tests.scenario_tests.test_view_submission import body as view_submission_body from tests.utils import remove_os_env_temporarily, restore_os_env @@ -169,3 +172,21 @@ def handle_user_message(say_stream: SayStream): response = app.dispatch(request) assert response.status == 200 assert_target_called(called) + + def test_say_stream_is_none_for_view_submission(self): + app = App(client=self.web_client, request_verification_enabled=False) + called = {"value": False} + + @app.view("view-id") + def handle_view(ack, say_stream, context: BoltContext): + ack() + assert say_stream is None + assert context.say_stream is None + called["value"] = True + + request = BoltRequest( + body=f"payload={quote(json.dumps(view_submission_body))}", + ) + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) diff --git a/tests/scenario_tests_async/test_events_say_stream.py b/tests/scenario_tests_async/test_events_say_stream.py index dcb3f18f6..a8e237086 100644 --- a/tests/scenario_tests_async/test_events_say_stream.py +++ b/tests/scenario_tests_async/test_events_say_stream.py @@ -1,5 +1,7 @@ import asyncio +import json import time +from urllib.parse import quote import pytest from slack_sdk.web.async_client import AsyncWebClient @@ -18,6 +20,7 @@ from tests.scenario_tests_async.test_events_assistant import user_message_event_body as threaded_user_message_event_body from tests.scenario_tests_async.test_events_assistant import thread_started_event_body, user_message_event_body from tests.scenario_tests_async.test_message_bot import bot_message_event_payload, user_message_event_payload +from tests.scenario_tests_async.test_view_submission import body as view_submission_body from tests.utils import remove_os_env_temporarily, restore_os_env @@ -179,3 +182,22 @@ async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltCont response = await app.async_dispatch(request) assert response.status == 200 await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_is_none_for_view_submission(self): + app = AsyncApp(client=self.web_client, request_verification_enabled=False) + called = {"value": False} + + @app.view("view-id") + async def handle_view(ack, say_stream, context: AsyncBoltContext): + await ack() + assert say_stream is None + assert context.say_stream is None + called["value"] = True + + request = AsyncBoltRequest( + body=f"payload={quote(json.dumps(view_submission_body))}", + ) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) From a4a63c8d3c82e9d84bf650ec886f81e4ff8c97ec Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 17 Mar 2026 18:08:48 -0400 Subject: [PATCH 04/12] claude found some stuff to fix --- slack_bolt/async_app.py | 2 ++ slack_bolt/context/base_context.py | 1 + 2 files changed, 3 insertions(+) diff --git a/slack_bolt/async_app.py b/slack_bolt/async_app.py index fdf724d4c..f95d952aa 100644 --- a/slack_bolt/async_app.py +++ b/slack_bolt/async_app.py @@ -59,6 +59,7 @@ async def command(ack, body, respond): from .context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from .context.get_thread_context.async_get_thread_context import AsyncGetThreadContext from .context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext +from .context.say_stream.async_say_stream import AsyncSayStream __all__ = [ "AsyncApp", @@ -66,6 +67,7 @@ async def command(ack, body, respond): "AsyncBoltContext", "AsyncRespond", "AsyncSay", + "AsyncSayStream", "AsyncListener", "AsyncCustomListenerMatcher", "AsyncBoltRequest", diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 843d5ef60..502febcb8 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", + "say_stream", ] # 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. From 55ea797920856d7681f1ebb22a5904516b37910b Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 17 Mar 2026 18:42:10 -0400 Subject: [PATCH 05/12] fix python 3.7 test issues --- tests/slack_bolt/context/test_say_stream.py | 64 ++++++++-------- .../context/test_async_say_stream.py | 75 ++++++++++--------- 2 files changed, 73 insertions(+), 66 deletions(-) diff --git a/tests/slack_bolt/context/test_say_stream.py b/tests/slack_bolt/context/test_say_stream.py index a38c6c5ae..87b0c8ba8 100644 --- a/tests/slack_bolt/context/test_say_stream.py +++ b/tests/slack_bolt/context/test_say_stream.py @@ -1,82 +1,82 @@ -from unittest.mock import MagicMock - import pytest from slack_sdk import WebClient from slack_bolt.context.say_stream.say_stream import SayStream from slack_bolt.warning import ExperimentalWarning +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server class TestSayStream: def setup_method(self): - self.mock_client = MagicMock(spec=WebClient) - self.mock_client.chat_stream = MagicMock() + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) def test_missing_channel_raises(self): - say_stream = SayStream(client=self.mock_client, channel_id=None, thread_ts="111.222") + say_stream = SayStream(client=self.web_client, channel_id=None, thread_ts="111.222") with pytest.warns(ExperimentalWarning): with pytest.raises(ValueError, match="channel"): say_stream() def test_missing_thread_ts_raises(self): - say_stream = SayStream(client=self.mock_client, channel_id="C111", thread_ts=None) + say_stream = SayStream(client=self.web_client, channel_id="C111", thread_ts=None) with pytest.warns(ExperimentalWarning): with pytest.raises(ValueError, match="thread_ts"): say_stream() def test_default_params(self): say_stream = SayStream( - client=self.mock_client, + client=self.web_client, channel_id="C111", thread_ts="111.222", team_id="T111", user_id="U111", ) - say_stream() + stream = say_stream() - self.mock_client.chat_stream.assert_called_once_with( - channel="C111", - thread_ts="111.222", - recipient_team_id="T111", - recipient_user_id="U111", - ) + assert stream._stream_args == { + "channel": "C111", + "thread_ts": "111.222", + "recipient_team_id": "T111", + "recipient_user_id": "U111", + "task_display_mode": None, + } def test_parameter_overrides(self): say_stream = SayStream( - client=self.mock_client, + client=self.web_client, channel_id="C111", thread_ts="111.222", team_id="T111", user_id="U111", ) - say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + stream = say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") - self.mock_client.chat_stream.assert_called_once_with( - channel="C222", - thread_ts="333.444", - recipient_team_id="T222", - recipient_user_id="U222", - ) + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } def test_buffer_size_passthrough(self): say_stream = SayStream( - client=self.mock_client, + client=self.web_client, channel_id="C111", thread_ts="111.222", ) - say_stream(buffer_size=100) + stream = say_stream(buffer_size=100) - self.mock_client.chat_stream.assert_called_once_with( - buffer_size=100, - channel="C111", - thread_ts="111.222", - recipient_team_id=None, - recipient_user_id=None, - ) + assert stream._buffer_size == 100 def test_experimental_warning(self): say_stream = SayStream( - client=self.mock_client, + client=self.web_client, channel_id="C111", thread_ts="111.222", ) diff --git a/tests/slack_bolt_async/context/test_async_say_stream.py b/tests/slack_bolt_async/context/test_async_say_stream.py index ba2285f32..ac09b17a4 100644 --- a/tests/slack_bolt_async/context/test_async_say_stream.py +++ b/tests/slack_bolt_async/context/test_async_say_stream.py @@ -1,27 +1,39 @@ -from unittest.mock import AsyncMock, MagicMock - import pytest from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream from slack_bolt.warning import ExperimentalWarning +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncSayStream: - def setup_method(self): - self.mock_client = MagicMock(spec=AsyncWebClient) - self.mock_client.chat_stream = AsyncMock() + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) @pytest.mark.asyncio async def test_missing_channel_raises(self): - say_stream = AsyncSayStream(client=self.mock_client, channel_id=None, thread_ts="111.222") + say_stream = AsyncSayStream(client=self.web_client, channel_id=None, thread_ts="111.222") with pytest.warns(ExperimentalWarning): with pytest.raises(ValueError, match="channel"): await say_stream() @pytest.mark.asyncio async def test_missing_thread_ts_raises(self): - say_stream = AsyncSayStream(client=self.mock_client, channel_id="C111", thread_ts=None) + say_stream = AsyncSayStream(client=self.web_client, channel_id="C111", thread_ts=None) with pytest.warns(ExperimentalWarning): with pytest.raises(ValueError, match="thread_ts"): await say_stream() @@ -29,60 +41,55 @@ async def test_missing_thread_ts_raises(self): @pytest.mark.asyncio async def test_default_params(self): say_stream = AsyncSayStream( - client=self.mock_client, + client=self.web_client, channel_id="C111", thread_ts="111.222", team_id="T111", user_id="U111", ) - await say_stream() - - self.mock_client.chat_stream.assert_called_once_with( - channel="C111", - thread_ts="111.222", - recipient_team_id="T111", - recipient_user_id="U111", - ) + stream = await say_stream() + assert stream._stream_args == { + "channel": "C111", + "thread_ts": "111.222", + "recipient_team_id": "T111", + "recipient_user_id": "U111", + "task_display_mode": None, + } @pytest.mark.asyncio async def test_parameter_overrides(self): say_stream = AsyncSayStream( - client=self.mock_client, + client=self.web_client, channel_id="C111", thread_ts="111.222", team_id="T111", user_id="U111", ) - await say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + stream = await say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") - self.mock_client.chat_stream.assert_called_once_with( - channel="C222", - thread_ts="333.444", - recipient_team_id="T222", - recipient_user_id="U222", - ) + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } @pytest.mark.asyncio async def test_buffer_size_passthrough(self): say_stream = AsyncSayStream( - client=self.mock_client, + client=self.web_client, channel_id="C111", thread_ts="111.222", ) - await say_stream(buffer_size=100) + stream = await say_stream(buffer_size=100) - self.mock_client.chat_stream.assert_called_once_with( - buffer_size=100, - channel="C111", - thread_ts="111.222", - recipient_team_id=None, - recipient_user_id=None, - ) + assert stream._buffer_size == 100 @pytest.mark.asyncio async def test_experimental_warning(self): say_stream = AsyncSayStream( - client=self.mock_client, + client=self.web_client, channel_id="C111", thread_ts="111.222", ) From 6a7f0f7df50c83c4b2c50c2581e678a9c31623b9 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 18 Mar 2026 07:06:04 -0700 Subject: [PATCH 06/12] Update tests/scenario_tests/test_events_say_stream.py Co-authored-by: Eden Zimbelman --- tests/scenario_tests/test_events_say_stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/scenario_tests/test_events_say_stream.py b/tests/scenario_tests/test_events_say_stream.py index b58cce817..5ae49a6a4 100644 --- a/tests/scenario_tests/test_events_say_stream.py +++ b/tests/scenario_tests/test_events_say_stream.py @@ -106,7 +106,7 @@ def test_say_stream_in_bot_message(self): called = {"value": False} @app.message("") - def handle_user_message(say_stream: SayStream): + def handle_bot_message(say_stream: SayStream): assert say_stream is not None assert isinstance(say_stream, SayStream) assert say_stream.channel_id == "C111" From 42b6c4d217e7a3c75168fd1062dd7af72effb777 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 18 Mar 2026 12:30:36 -0400 Subject: [PATCH 07/12] Improve conssistancy in naming --- .../context/say_stream/async_say_stream.py | 24 +++++++++---------- slack_bolt/context/say_stream/say_stream.py | 24 +++++++++---------- .../async_attaching_agent_kwargs.py | 6 ++--- .../attaching_agent_kwargs.py | 6 ++--- .../scenario_tests/test_events_say_stream.py | 22 +++++++++-------- .../test_events_say_stream.py | 22 +++++++++-------- tests/slack_bolt/context/test_say_stream.py | 20 ++++++++-------- .../context/test_async_say_stream.py | 20 ++++++++-------- 8 files changed, 74 insertions(+), 70 deletions(-) diff --git a/slack_bolt/context/say_stream/async_say_stream.py b/slack_bolt/context/say_stream/async_say_stream.py index df6336520..f49d606b5 100644 --- a/slack_bolt/context/say_stream/async_say_stream.py +++ b/slack_bolt/context/say_stream/async_say_stream.py @@ -9,25 +9,25 @@ class AsyncSayStream: client: AsyncWebClient - channel_id: Optional[str] + channel: Optional[str] thread_ts: Optional[str] - team_id: Optional[str] - user_id: Optional[str] + recipient_team_id: Optional[str] + recipient_user_id: Optional[str] def __init__( self, *, client: AsyncWebClient, - channel_id: Optional[str] = None, + channel: Optional[str] = None, thread_ts: Optional[str] = None, - team_id: Optional[str] = None, - user_id: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, ): self.client = client - self.channel_id = channel_id + self.channel = channel self.thread_ts = thread_ts - self.team_id = team_id - self.user_id = user_id + self.recipient_team_id = recipient_team_id + self.recipient_user_id = recipient_user_id async def __call__( self, @@ -44,7 +44,7 @@ async def __call__( stacklevel=2, ) - channel = channel or self.channel_id + channel = channel or self.channel thread_ts = thread_ts or self.thread_ts if channel is None: raise ValueError("say_stream without channel here is unsupported") @@ -54,7 +54,7 @@ async def __call__( return await self.client.chat_stream( channel=channel, thread_ts=thread_ts, - recipient_team_id=recipient_team_id or self.team_id, - recipient_user_id=recipient_user_id or self.user_id, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, **kwargs, ) diff --git a/slack_bolt/context/say_stream/say_stream.py b/slack_bolt/context/say_stream/say_stream.py index 91e42b9a1..cbea61394 100644 --- a/slack_bolt/context/say_stream/say_stream.py +++ b/slack_bolt/context/say_stream/say_stream.py @@ -9,25 +9,25 @@ class SayStream: client: WebClient - channel_id: Optional[str] + channel: Optional[str] thread_ts: Optional[str] - team_id: Optional[str] - user_id: Optional[str] + recipient_team_id: Optional[str] + recipient_user_id: Optional[str] def __init__( self, *, client: WebClient, - channel_id: Optional[str] = None, + channel: Optional[str] = None, thread_ts: Optional[str] = None, - team_id: Optional[str] = None, - user_id: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, ): self.client = client - self.channel_id = channel_id + self.channel = channel self.thread_ts = thread_ts - self.team_id = team_id - self.user_id = user_id + self.recipient_team_id = recipient_team_id + self.recipient_user_id = recipient_user_id def __call__( self, @@ -44,7 +44,7 @@ def __call__( stacklevel=2, ) - channel = channel or self.channel_id + channel = channel or self.channel thread_ts = thread_ts or self.thread_ts if channel is None: raise ValueError("say_stream without channel here is unsupported") @@ -54,7 +54,7 @@ def __call__( return self.client.chat_stream( channel=channel, thread_ts=thread_ts, - recipient_team_id=recipient_team_id or self.team_id, - recipient_user_id=recipient_user_id or self.user_id, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, **kwargs, ) diff --git a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py index 11548c4a8..7c776a25b 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py @@ -43,9 +43,9 @@ async def async_process( if req.context.channel_id and thread_ts: req.context["say_stream"] = AsyncSayStream( client=req.context.client, - channel_id=req.context.channel_id, + channel=req.context.channel_id, thread_ts=thread_ts, - team_id=req.context.team_id, - user_id=req.context.user_id, + recipient_team_id=req.context.team_id, + recipient_user_id=req.context.user_id, ) return await next() diff --git a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py index a8e7862a6..e3d4f3ded 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py @@ -37,9 +37,9 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo if req.context.channel_id and thread_ts: req.context["say_stream"] = SayStream( client=req.context.client, - channel_id=req.context.channel_id, + channel=req.context.channel_id, thread_ts=thread_ts, - team_id=req.context.team_id, - user_id=req.context.user_id, + recipient_team_id=req.context.team_id, + recipient_user_id=req.context.user_id, ) return next() diff --git a/tests/scenario_tests/test_events_say_stream.py b/tests/scenario_tests/test_events_say_stream.py index 5ae49a6a4..ee1c7da03 100644 --- a/tests/scenario_tests/test_events_say_stream.py +++ b/tests/scenario_tests/test_events_say_stream.py @@ -54,10 +54,11 @@ def test_say_stream_injected_for_app_mention(self): def handle_mention(say_stream: SayStream, context: BoltContext): assert say_stream is not None assert isinstance(say_stream, SayStream) - assert say_stream.channel_id == "C111" + assert say_stream == context.say_stream + assert say_stream.channel == "C111" assert say_stream.thread_ts == "1595926230.009600" - assert say_stream.team_id == context.team_id - assert say_stream.user_id == context.user_id + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True request = BoltRequest(body=app_mention_event_body, mode="socket_mode") @@ -73,10 +74,11 @@ def test_say_stream_injected_for_threaded_message(self): def handle_message(say_stream: SayStream, context: BoltContext): assert say_stream is not None assert isinstance(say_stream, SayStream) - assert say_stream.channel_id == "D111" + assert say_stream == context.say_stream + assert say_stream.channel == "D111" assert say_stream.thread_ts == "1726133698.626339" - assert say_stream.team_id == context.team_id - assert say_stream.user_id == context.user_id + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True request = BoltRequest(body=threaded_user_message_event_body, mode="socket_mode") @@ -92,7 +94,7 @@ def test_say_stream_in_user_message(self): def handle_user_message(say_stream: SayStream): assert say_stream is not None assert isinstance(say_stream, SayStream) - assert say_stream.channel_id == "C111" + assert say_stream.channel == "C111" assert say_stream.thread_ts == "1610261659.001400" called["value"] = True @@ -109,7 +111,7 @@ def test_say_stream_in_bot_message(self): def handle_bot_message(say_stream: SayStream): assert say_stream is not None assert isinstance(say_stream, SayStream) - assert say_stream.channel_id == "C111" + assert say_stream.channel == "C111" assert say_stream.thread_ts == "1610261539.000900" called["value"] = True @@ -142,7 +144,7 @@ def test_say_stream_in_assistant_thread_started(self): def start_thread(say_stream: SayStream): assert say_stream is not None assert isinstance(say_stream, SayStream) - assert say_stream.channel_id == "D111" + assert say_stream.channel == "D111" assert say_stream.thread_ts == "1726133698.626339" called["value"] = True @@ -162,7 +164,7 @@ def test_say_stream_in_assistant_user_message(self): def handle_user_message(say_stream: SayStream): assert say_stream is not None assert isinstance(say_stream, SayStream) - assert say_stream.channel_id == "D111" + assert say_stream.channel == "D111" assert say_stream.thread_ts == "1726133698.626339" called["value"] = True diff --git a/tests/scenario_tests_async/test_events_say_stream.py b/tests/scenario_tests_async/test_events_say_stream.py index a8e237086..11c057625 100644 --- a/tests/scenario_tests_async/test_events_say_stream.py +++ b/tests/scenario_tests_async/test_events_say_stream.py @@ -58,10 +58,11 @@ async def test_say_stream_injected_for_app_mention(self): async def handle_mention(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream is not None assert isinstance(say_stream, AsyncSayStream) - assert say_stream.channel_id == "C111" + assert say_stream == context.say_stream + assert say_stream.channel == "C111" assert say_stream.thread_ts == "1595926230.009600" - assert say_stream.team_id == context.team_id - assert say_stream.user_id == context.user_id + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") @@ -78,10 +79,11 @@ async def test_say_stream_injected_for_threaded_message(self): async def handle_message(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream is not None assert isinstance(say_stream, AsyncSayStream) - assert say_stream.channel_id == "D111" + assert say_stream == context.say_stream + assert say_stream.channel == "D111" assert say_stream.thread_ts == "1726133698.626339" - assert say_stream.team_id == context.team_id - assert say_stream.user_id == context.user_id + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True request = AsyncBoltRequest(body=threaded_user_message_event_body, mode="socket_mode") @@ -98,7 +100,7 @@ async def test_say_stream_in_user_message(self): async def handle_user_message(say_stream: AsyncSayStream): assert say_stream is not None assert isinstance(say_stream, AsyncSayStream) - assert say_stream.channel_id == "C111" + assert say_stream.channel == "C111" assert say_stream.thread_ts == "1610261659.001400" called["value"] = True @@ -116,7 +118,7 @@ async def test_say_stream_in_bot_message(self): async def handle_user_message(say_stream: AsyncSayStream): assert say_stream is not None assert isinstance(say_stream, AsyncSayStream) - assert say_stream.channel_id == "C111" + assert say_stream.channel == "C111" assert say_stream.thread_ts == "1610261539.000900" called["value"] = True @@ -151,7 +153,7 @@ async def test_say_stream_in_assistant_thread_started(self): async def start_thread(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream is not None assert isinstance(say_stream, AsyncSayStream) - assert say_stream.channel_id == "D111" + assert say_stream.channel == "D111" assert say_stream.thread_ts == "1726133698.626339" called["value"] = True @@ -172,7 +174,7 @@ async def test_say_stream_in_assistant_user_message(self): async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream is not None assert isinstance(say_stream, AsyncSayStream) - assert say_stream.channel_id == "D111" + assert say_stream.channel == "D111" assert say_stream.thread_ts == "1726133698.626339" called["value"] = True diff --git a/tests/slack_bolt/context/test_say_stream.py b/tests/slack_bolt/context/test_say_stream.py index 87b0c8ba8..6f865f6b8 100644 --- a/tests/slack_bolt/context/test_say_stream.py +++ b/tests/slack_bolt/context/test_say_stream.py @@ -17,13 +17,13 @@ def teardown_method(self): cleanup_mock_web_api_server(self) def test_missing_channel_raises(self): - say_stream = SayStream(client=self.web_client, channel_id=None, thread_ts="111.222") + say_stream = SayStream(client=self.web_client, channel=None, thread_ts="111.222") with pytest.warns(ExperimentalWarning): with pytest.raises(ValueError, match="channel"): say_stream() def test_missing_thread_ts_raises(self): - say_stream = SayStream(client=self.web_client, channel_id="C111", thread_ts=None) + say_stream = SayStream(client=self.web_client, channel="C111", thread_ts=None) with pytest.warns(ExperimentalWarning): with pytest.raises(ValueError, match="thread_ts"): say_stream() @@ -31,10 +31,10 @@ def test_missing_thread_ts_raises(self): def test_default_params(self): say_stream = SayStream( client=self.web_client, - channel_id="C111", + channel="C111", thread_ts="111.222", - team_id="T111", - user_id="U111", + recipient_team_id="T111", + recipient_user_id="U111", ) stream = say_stream() @@ -49,10 +49,10 @@ def test_default_params(self): def test_parameter_overrides(self): say_stream = SayStream( client=self.web_client, - channel_id="C111", + channel="C111", thread_ts="111.222", - team_id="T111", - user_id="U111", + recipient_team_id="T111", + recipient_user_id="U111", ) stream = say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") @@ -67,7 +67,7 @@ def test_parameter_overrides(self): def test_buffer_size_passthrough(self): say_stream = SayStream( client=self.web_client, - channel_id="C111", + channel="C111", thread_ts="111.222", ) stream = say_stream(buffer_size=100) @@ -77,7 +77,7 @@ def test_buffer_size_passthrough(self): def test_experimental_warning(self): say_stream = SayStream( client=self.web_client, - channel_id="C111", + channel="C111", thread_ts="111.222", ) with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): diff --git a/tests/slack_bolt_async/context/test_async_say_stream.py b/tests/slack_bolt_async/context/test_async_say_stream.py index ac09b17a4..b18151fc6 100644 --- a/tests/slack_bolt_async/context/test_async_say_stream.py +++ b/tests/slack_bolt_async/context/test_async_say_stream.py @@ -26,14 +26,14 @@ def setup_teardown(self): @pytest.mark.asyncio async def test_missing_channel_raises(self): - say_stream = AsyncSayStream(client=self.web_client, channel_id=None, thread_ts="111.222") + say_stream = AsyncSayStream(client=self.web_client, channel=None, thread_ts="111.222") with pytest.warns(ExperimentalWarning): with pytest.raises(ValueError, match="channel"): await say_stream() @pytest.mark.asyncio async def test_missing_thread_ts_raises(self): - say_stream = AsyncSayStream(client=self.web_client, channel_id="C111", thread_ts=None) + say_stream = AsyncSayStream(client=self.web_client, channel="C111", thread_ts=None) with pytest.warns(ExperimentalWarning): with pytest.raises(ValueError, match="thread_ts"): await say_stream() @@ -42,10 +42,10 @@ async def test_missing_thread_ts_raises(self): async def test_default_params(self): say_stream = AsyncSayStream( client=self.web_client, - channel_id="C111", + channel="C111", thread_ts="111.222", - team_id="T111", - user_id="U111", + recipient_team_id="T111", + recipient_user_id="U111", ) stream = await say_stream() assert stream._stream_args == { @@ -60,10 +60,10 @@ async def test_default_params(self): async def test_parameter_overrides(self): say_stream = AsyncSayStream( client=self.web_client, - channel_id="C111", + channel="C111", thread_ts="111.222", - team_id="T111", - user_id="U111", + recipient_team_id="T111", + recipient_user_id="U111", ) stream = await say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") @@ -79,7 +79,7 @@ async def test_parameter_overrides(self): async def test_buffer_size_passthrough(self): say_stream = AsyncSayStream( client=self.web_client, - channel_id="C111", + channel="C111", thread_ts="111.222", ) stream = await say_stream(buffer_size=100) @@ -90,7 +90,7 @@ async def test_buffer_size_passthrough(self): async def test_experimental_warning(self): say_stream = AsyncSayStream( client=self.web_client, - channel_id="C111", + channel="C111", thread_ts="111.222", ) with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): From eda8e73c436ec6ecdf391c9447bb269bc61a5d96 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 18 Mar 2026 13:06:17 -0400 Subject: [PATCH 08/12] surface the buffer_size override --- .../context/say_stream/async_say_stream.py | 12 +++++++++- slack_bolt/context/say_stream/say_stream.py | 12 +++++++++- tests/slack_bolt/context/test_say_stream.py | 23 ++++++++++++++++-- .../context/test_async_say_stream.py | 24 +++++++++++++++++-- 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/slack_bolt/context/say_stream/async_say_stream.py b/slack_bolt/context/say_stream/async_say_stream.py index f49d606b5..c497c8c69 100644 --- a/slack_bolt/context/say_stream/async_say_stream.py +++ b/slack_bolt/context/say_stream/async_say_stream.py @@ -32,10 +32,11 @@ def __init__( async def __call__( self, *, + buffer_size: Optional[int] = None, channel: Optional[str] = None, - thread_ts: Optional[str] = None, recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, + thread_ts: Optional[str] = None, **kwargs, ) -> AsyncChatStream: warnings.warn( @@ -51,6 +52,15 @@ async def __call__( if thread_ts is None: raise ValueError("say_stream without thread_ts here is unsupported") + if buffer_size is not None: + return await self.client.chat_stream( + channel=channel, + thread_ts=thread_ts, + buffer_size=buffer_size, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, + **kwargs, + ) return await self.client.chat_stream( channel=channel, thread_ts=thread_ts, diff --git a/slack_bolt/context/say_stream/say_stream.py b/slack_bolt/context/say_stream/say_stream.py index cbea61394..a13fa294a 100644 --- a/slack_bolt/context/say_stream/say_stream.py +++ b/slack_bolt/context/say_stream/say_stream.py @@ -32,10 +32,11 @@ def __init__( def __call__( self, *, + buffer_size: Optional[int] = None, channel: Optional[str] = None, - thread_ts: Optional[str] = None, recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, + thread_ts: Optional[str] = None, **kwargs, ) -> ChatStream: warnings.warn( @@ -51,6 +52,15 @@ def __call__( if thread_ts is None: raise ValueError("say_stream without thread_ts here is unsupported") + if buffer_size is not None: + return self.client.chat_stream( + channel=channel, + thread_ts=thread_ts, + buffer_size=buffer_size, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, + **kwargs, + ) return self.client.chat_stream( channel=channel, thread_ts=thread_ts, diff --git a/tests/slack_bolt/context/test_say_stream.py b/tests/slack_bolt/context/test_say_stream.py index 6f865f6b8..94105b5da 100644 --- a/tests/slack_bolt/context/test_say_stream.py +++ b/tests/slack_bolt/context/test_say_stream.py @@ -7,6 +7,8 @@ class TestSayStream: + default_chat_stream_buffer_size = WebClient.chat_stream.__kwdefaults__["buffer_size"] + def setup_method(self): setup_mock_web_api_server(self) valid_token = "xoxb-valid" @@ -38,6 +40,7 @@ def test_default_params(self): ) stream = say_stream() + assert stream._buffer_size == self.default_chat_stream_buffer_size assert stream._stream_args == { "channel": "C111", "thread_ts": "111.222", @@ -56,6 +59,7 @@ def test_parameter_overrides(self): ) stream = say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + assert stream._buffer_size == self.default_chat_stream_buffer_size assert stream._stream_args == { "channel": "C222", "thread_ts": "333.444", @@ -64,15 +68,30 @@ def test_parameter_overrides(self): "task_display_mode": None, } - def test_buffer_size_passthrough(self): + def test_buffer_size_overrides(self): say_stream = SayStream( client=self.web_client, channel="C111", thread_ts="111.222", + recipient_team_id="T111", + recipient_user_id="U111", + ) + stream = say_stream( + buffer_size=100, + channel="C222", + thread_ts="333.444", + recipient_team_id="T222", + recipient_user_id="U222", ) - stream = say_stream(buffer_size=100) assert stream._buffer_size == 100 + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } def test_experimental_warning(self): say_stream = SayStream( diff --git a/tests/slack_bolt_async/context/test_async_say_stream.py b/tests/slack_bolt_async/context/test_async_say_stream.py index b18151fc6..dc5fb3906 100644 --- a/tests/slack_bolt_async/context/test_async_say_stream.py +++ b/tests/slack_bolt_async/context/test_async_say_stream.py @@ -11,6 +11,8 @@ class TestAsyncSayStream: + default_chat_stream_buffer_size = AsyncWebClient.chat_stream.__kwdefaults__["buffer_size"] + @pytest.fixture(scope="function", autouse=True) def setup_teardown(self): old_os_env = remove_os_env_temporarily() @@ -48,6 +50,8 @@ async def test_default_params(self): recipient_user_id="U111", ) stream = await say_stream() + + assert stream._buffer_size == self.default_chat_stream_buffer_size assert stream._stream_args == { "channel": "C111", "thread_ts": "111.222", @@ -67,6 +71,7 @@ async def test_parameter_overrides(self): ) stream = await say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + assert stream._buffer_size == self.default_chat_stream_buffer_size assert stream._stream_args == { "channel": "C222", "thread_ts": "333.444", @@ -76,15 +81,30 @@ async def test_parameter_overrides(self): } @pytest.mark.asyncio - async def test_buffer_size_passthrough(self): + async def test_buffer_size_overrides(self): say_stream = AsyncSayStream( client=self.web_client, channel="C111", thread_ts="111.222", + recipient_team_id="T111", + recipient_user_id="U111", + ) + stream = await say_stream( + buffer_size=100, + channel="C222", + thread_ts="333.444", + recipient_team_id="T222", + recipient_user_id="U222", ) - stream = await say_stream(buffer_size=100) assert stream._buffer_size == 100 + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } @pytest.mark.asyncio async def test_experimental_warning(self): From db99b51e6cc0ada308c223fdab8ca848a7ff0561 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 18 Mar 2026 13:21:06 -0400 Subject: [PATCH 09/12] make things alphabet --- slack_bolt/context/say_stream/async_say_stream.py | 12 ++++++------ slack_bolt/context/say_stream/say_stream.py | 12 ++++++------ .../async_attaching_agent_kwargs.py | 2 +- .../attaching_agent_kwargs/attaching_agent_kwargs.py | 2 +- tests/slack_bolt/context/test_say_stream.py | 6 +++--- .../context/test_async_say_stream.py | 6 +++--- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/slack_bolt/context/say_stream/async_say_stream.py b/slack_bolt/context/say_stream/async_say_stream.py index c497c8c69..e438b9fec 100644 --- a/slack_bolt/context/say_stream/async_say_stream.py +++ b/slack_bolt/context/say_stream/async_say_stream.py @@ -10,24 +10,24 @@ class AsyncSayStream: client: AsyncWebClient channel: Optional[str] - thread_ts: Optional[str] recipient_team_id: Optional[str] recipient_user_id: Optional[str] + thread_ts: Optional[str] def __init__( self, *, client: AsyncWebClient, channel: Optional[str] = None, - thread_ts: Optional[str] = None, recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, + thread_ts: Optional[str] = None, ): self.client = client self.channel = channel - self.thread_ts = thread_ts self.recipient_team_id = recipient_team_id self.recipient_user_id = recipient_user_id + self.thread_ts = thread_ts async def __call__( self, @@ -54,17 +54,17 @@ async def __call__( if buffer_size is not None: return await self.client.chat_stream( - channel=channel, - thread_ts=thread_ts, buffer_size=buffer_size, + channel=channel, recipient_team_id=recipient_team_id or self.recipient_team_id, recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, **kwargs, ) return await self.client.chat_stream( channel=channel, - thread_ts=thread_ts, recipient_team_id=recipient_team_id or self.recipient_team_id, recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, **kwargs, ) diff --git a/slack_bolt/context/say_stream/say_stream.py b/slack_bolt/context/say_stream/say_stream.py index a13fa294a..a36db6d70 100644 --- a/slack_bolt/context/say_stream/say_stream.py +++ b/slack_bolt/context/say_stream/say_stream.py @@ -10,24 +10,24 @@ class SayStream: client: WebClient channel: Optional[str] - thread_ts: Optional[str] recipient_team_id: Optional[str] recipient_user_id: Optional[str] + thread_ts: Optional[str] def __init__( self, *, client: WebClient, channel: Optional[str] = None, - thread_ts: Optional[str] = None, recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, + thread_ts: Optional[str] = None, ): self.client = client self.channel = channel - self.thread_ts = thread_ts self.recipient_team_id = recipient_team_id self.recipient_user_id = recipient_user_id + self.thread_ts = thread_ts def __call__( self, @@ -54,17 +54,17 @@ def __call__( if buffer_size is not None: return self.client.chat_stream( - channel=channel, - thread_ts=thread_ts, buffer_size=buffer_size, + channel=channel, recipient_team_id=recipient_team_id or self.recipient_team_id, recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, **kwargs, ) return self.client.chat_stream( channel=channel, - thread_ts=thread_ts, recipient_team_id=recipient_team_id or self.recipient_team_id, recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, **kwargs, ) diff --git a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py index 7c776a25b..188c22833 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py @@ -44,8 +44,8 @@ async def async_process( req.context["say_stream"] = AsyncSayStream( client=req.context.client, channel=req.context.channel_id, - thread_ts=thread_ts, recipient_team_id=req.context.team_id, recipient_user_id=req.context.user_id, + thread_ts=thread_ts, ) return await next() diff --git a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py index e3d4f3ded..007404bfd 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py @@ -38,8 +38,8 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo req.context["say_stream"] = SayStream( client=req.context.client, channel=req.context.channel_id, - thread_ts=thread_ts, recipient_team_id=req.context.team_id, recipient_user_id=req.context.user_id, + thread_ts=thread_ts, ) return next() diff --git a/tests/slack_bolt/context/test_say_stream.py b/tests/slack_bolt/context/test_say_stream.py index 94105b5da..c8f4c3a31 100644 --- a/tests/slack_bolt/context/test_say_stream.py +++ b/tests/slack_bolt/context/test_say_stream.py @@ -34,9 +34,9 @@ def test_default_params(self): say_stream = SayStream( client=self.web_client, channel="C111", - thread_ts="111.222", recipient_team_id="T111", recipient_user_id="U111", + thread_ts="111.222", ) stream = say_stream() @@ -53,9 +53,9 @@ def test_parameter_overrides(self): say_stream = SayStream( client=self.web_client, channel="C111", - thread_ts="111.222", recipient_team_id="T111", recipient_user_id="U111", + thread_ts="111.222", ) stream = say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") @@ -72,9 +72,9 @@ def test_buffer_size_overrides(self): say_stream = SayStream( client=self.web_client, channel="C111", - thread_ts="111.222", recipient_team_id="T111", recipient_user_id="U111", + thread_ts="111.222", ) stream = say_stream( buffer_size=100, diff --git a/tests/slack_bolt_async/context/test_async_say_stream.py b/tests/slack_bolt_async/context/test_async_say_stream.py index dc5fb3906..fbc4c5c7e 100644 --- a/tests/slack_bolt_async/context/test_async_say_stream.py +++ b/tests/slack_bolt_async/context/test_async_say_stream.py @@ -45,9 +45,9 @@ async def test_default_params(self): say_stream = AsyncSayStream( client=self.web_client, channel="C111", - thread_ts="111.222", recipient_team_id="T111", recipient_user_id="U111", + thread_ts="111.222", ) stream = await say_stream() @@ -65,9 +65,9 @@ async def test_parameter_overrides(self): say_stream = AsyncSayStream( client=self.web_client, channel="C111", - thread_ts="111.222", recipient_team_id="T111", recipient_user_id="U111", + thread_ts="111.222", ) stream = await say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") @@ -85,9 +85,9 @@ async def test_buffer_size_overrides(self): say_stream = AsyncSayStream( client=self.web_client, channel="C111", - thread_ts="111.222", recipient_team_id="T111", recipient_user_id="U111", + thread_ts="111.222", ) stream = await say_stream( buffer_size=100, From 9aaedf7b4ed1d1dcc8042e0cb4380679117892cd Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 18 Mar 2026 13:40:14 -0400 Subject: [PATCH 10/12] improving asserts in unit tests --- .../scenario_tests/test_events_say_stream.py | 37 ++++++++----------- .../test_events_say_stream.py | 33 +++++++---------- 2 files changed, 30 insertions(+), 40 deletions(-) diff --git a/tests/scenario_tests/test_events_say_stream.py b/tests/scenario_tests/test_events_say_stream.py index ee1c7da03..f41f0ae84 100644 --- a/tests/scenario_tests/test_events_say_stream.py +++ b/tests/scenario_tests/test_events_say_stream.py @@ -2,13 +2,11 @@ import time from urllib.parse import quote -import pytest from slack_sdk.web import WebClient from slack_bolt import App, BoltRequest, BoltContext from slack_bolt.context.say_stream.say_stream import SayStream from slack_bolt.middleware.assistant import Assistant -from slack_bolt.warning import ExperimentalWarning from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -91,11 +89,14 @@ def test_say_stream_in_user_message(self): called = {"value": False} @app.message("") - def handle_user_message(say_stream: SayStream): + def handle_user_message(say_stream: SayStream, context: BoltContext): assert say_stream is not None assert isinstance(say_stream, SayStream) + assert say_stream == context.say_stream assert say_stream.channel == "C111" assert say_stream.thread_ts == "1610261659.001400" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True request = BoltRequest(body=user_message_event_payload, mode="socket_mode") @@ -108,11 +109,14 @@ def test_say_stream_in_bot_message(self): called = {"value": False} @app.message("") - def handle_bot_message(say_stream: SayStream): + def handle_bot_message(say_stream: SayStream, context: BoltContext): assert say_stream is not None assert isinstance(say_stream, SayStream) + assert say_stream == context.say_stream assert say_stream.channel == "C111" assert say_stream.thread_ts == "1610261539.000900" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True request = BoltRequest(body=bot_message_event_payload, mode="socket_mode") @@ -120,32 +124,20 @@ def handle_bot_message(say_stream: SayStream): assert response.status == 200 assert_target_called(called) - def test_say_stream_kwarg_emits_experimental_warning(self): - app = App(client=self.web_client) - called = {"value": False} - - @app.event("app_mention") - def handle_mention(say_stream: SayStream): - with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): - say_stream() - called["value"] = True - - request = BoltRequest(body=app_mention_event_body, mode="socket_mode") - response = app.dispatch(request) - assert response.status == 200 - assert_target_called(called) - def test_say_stream_in_assistant_thread_started(self): app = App(client=self.web_client) assistant = Assistant() called = {"value": False} @assistant.thread_started - def start_thread(say_stream: SayStream): + def start_thread(say_stream: SayStream, context: BoltContext): assert say_stream is not None assert isinstance(say_stream, SayStream) + assert say_stream == context.say_stream assert say_stream.channel == "D111" assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True app.assistant(assistant) @@ -161,11 +153,14 @@ def test_say_stream_in_assistant_user_message(self): called = {"value": False} @assistant.user_message - def handle_user_message(say_stream: SayStream): + def handle_user_message(say_stream: SayStream, context: BoltContext): assert say_stream is not None assert isinstance(say_stream, SayStream) + assert say_stream == context.say_stream assert say_stream.channel == "D111" assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True app.assistant(assistant) diff --git a/tests/scenario_tests_async/test_events_say_stream.py b/tests/scenario_tests_async/test_events_say_stream.py index 11c057625..31ff91fa3 100644 --- a/tests/scenario_tests_async/test_events_say_stream.py +++ b/tests/scenario_tests_async/test_events_say_stream.py @@ -11,7 +11,6 @@ from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream 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, @@ -97,11 +96,14 @@ async def test_say_stream_in_user_message(self): called = {"value": False} @app.message("") - async def handle_user_message(say_stream: AsyncSayStream): + async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream is not None assert isinstance(say_stream, AsyncSayStream) + assert say_stream == context.say_stream assert say_stream.channel == "C111" assert say_stream.thread_ts == "1610261659.001400" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True request = AsyncBoltRequest(body=user_message_event_payload, mode="socket_mode") @@ -115,11 +117,14 @@ async def test_say_stream_in_bot_message(self): called = {"value": False} @app.message("") - async def handle_user_message(say_stream: AsyncSayStream): + async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream is not None assert isinstance(say_stream, AsyncSayStream) + assert say_stream == context.say_stream assert say_stream.channel == "C111" assert say_stream.thread_ts == "1610261539.000900" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True request = AsyncBoltRequest(body=bot_message_event_payload, mode="socket_mode") @@ -127,22 +132,6 @@ async def handle_user_message(say_stream: AsyncSayStream): assert response.status == 200 await assert_target_called(called) - @pytest.mark.asyncio - async def test_say_stream_kwarg_emits_experimental_warning(self): - app = AsyncApp(client=self.web_client) - called = {"value": False} - - @app.event("app_mention") - async def handle_mention(say_stream: AsyncSayStream): - with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): - await say_stream() - called["value"] = 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(called) - @pytest.mark.asyncio async def test_say_stream_in_assistant_thread_started(self): app = AsyncApp(client=self.web_client) @@ -153,8 +142,11 @@ async def test_say_stream_in_assistant_thread_started(self): async def start_thread(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream is not None assert isinstance(say_stream, AsyncSayStream) + assert say_stream == context.say_stream assert say_stream.channel == "D111" assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True app.assistant(assistant) @@ -174,8 +166,11 @@ async def test_say_stream_in_assistant_user_message(self): async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream is not None assert isinstance(say_stream, AsyncSayStream) + assert say_stream == context.say_stream assert say_stream.channel == "D111" assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id called["value"] = True app.assistant(assistant) From 143e8e37775839f236fb0c3becb924d77d3a9042 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 18 Mar 2026 15:56:18 -0400 Subject: [PATCH 11/12] make things work for org apps --- .../async_attaching_agent_kwargs.py | 2 +- .../attaching_agent_kwargs.py | 2 +- .../scenario_tests/test_events_say_stream.py | 49 ++++++++++++++++++ .../test_events_say_stream.py | 50 +++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py index 188c22833..08851c1eb 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py @@ -44,7 +44,7 @@ async def async_process( req.context["say_stream"] = AsyncSayStream( client=req.context.client, channel=req.context.channel_id, - recipient_team_id=req.context.team_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, recipient_user_id=req.context.user_id, thread_ts=thread_ts, ) diff --git a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py index 007404bfd..38a62c0c8 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py @@ -38,7 +38,7 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo req.context["say_stream"] = SayStream( client=req.context.client, channel=req.context.channel_id, - recipient_team_id=req.context.team_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, recipient_user_id=req.context.user_id, thread_ts=thread_ts, ) diff --git a/tests/scenario_tests/test_events_say_stream.py b/tests/scenario_tests/test_events_say_stream.py index f41f0ae84..75b0c612c 100644 --- a/tests/scenario_tests/test_events_say_stream.py +++ b/tests/scenario_tests/test_events_say_stream.py @@ -64,6 +64,24 @@ def handle_mention(say_stream: SayStream, context: BoltContext): assert response.status == 200 assert_target_called(called) + def test_say_stream_with_org_level_install(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("app_mention") + def handle_mention(say_stream: SayStream, context: BoltContext): + assert context.team_id is None + assert context.enterprise_id == "E111" + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream.recipient_team_id == "E111" + called["value"] = True + + request = BoltRequest(body=org_app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + def test_say_stream_injected_for_threaded_message(self): app = App(client=self.web_client) called = {"value": False} @@ -187,3 +205,34 @@ def handle_view(ack, say_stream, context: BoltContext): response = app.dispatch(request) assert response.status == 200 assert_target_called(called) + + +org_app_mention_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, +} diff --git a/tests/scenario_tests_async/test_events_say_stream.py b/tests/scenario_tests_async/test_events_say_stream.py index 31ff91fa3..c24bc7bfc 100644 --- a/tests/scenario_tests_async/test_events_say_stream.py +++ b/tests/scenario_tests_async/test_events_say_stream.py @@ -69,6 +69,25 @@ async def handle_mention(say_stream: AsyncSayStream, context: AsyncBoltContext): assert response.status == 200 await assert_target_called(called) + @pytest.mark.asyncio + async def test_say_stream_with_org_level_install(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("app_mention") + async def handle_mention(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert context.team_id is None + assert context.enterprise_id == "E111" + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream.recipient_team_id == "E111" + called["value"] = True + + request = AsyncBoltRequest(body=org_app_mention_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + @pytest.mark.asyncio async def test_say_stream_injected_for_threaded_message(self): app = AsyncApp(client=self.web_client) @@ -198,3 +217,34 @@ async def handle_view(ack, say_stream, context: AsyncBoltContext): response = await app.async_dispatch(request) assert response.status == 200 await assert_target_called(called) + + +org_app_mention_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, +} From 393f9b0d9313ad1f66eb9d8d9040371bd54670c0 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 19 Mar 2026 09:52:13 -0400 Subject: [PATCH 12/12] Add warning to docstring --- slack_bolt/context/say_stream/async_say_stream.py | 4 ++++ slack_bolt/context/say_stream/say_stream.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/slack_bolt/context/say_stream/async_say_stream.py b/slack_bolt/context/say_stream/async_say_stream.py index e438b9fec..dc752d02a 100644 --- a/slack_bolt/context/say_stream/async_say_stream.py +++ b/slack_bolt/context/say_stream/async_say_stream.py @@ -39,6 +39,10 @@ async def __call__( thread_ts: Optional[str] = None, **kwargs, ) -> AsyncChatStream: + """Starts a new chat stream with context. + + Warning: This is an experimental feature and may change in future versions. + """ warnings.warn( "say_stream is experimental and may change in future versions.", category=ExperimentalWarning, diff --git a/slack_bolt/context/say_stream/say_stream.py b/slack_bolt/context/say_stream/say_stream.py index a36db6d70..1e1d7985f 100644 --- a/slack_bolt/context/say_stream/say_stream.py +++ b/slack_bolt/context/say_stream/say_stream.py @@ -39,6 +39,10 @@ def __call__( thread_ts: Optional[str] = None, **kwargs, ) -> ChatStream: + """Starts a new chat stream with context. + + Warning: This is an experimental feature and may change in future versions. + """ warnings.warn( "say_stream is experimental and may change in future versions.", category=ExperimentalWarning,