From 1e1c1677b2d523d6326745bec345e287275ae3af Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 16 Feb 2026 13:04:20 -0800 Subject: [PATCH 1/6] feat: attach ts to listener context --- slack_bolt/context/base_context.py | 6 +++++ slack_bolt/kwargs_injection/async_utils.py | 10 ++++++++- slack_bolt/kwargs_injection/utils.py | 10 ++++++++- slack_bolt/request/async_internals.py | 4 ++++ slack_bolt/request/internals.py | 26 +++++++++++++++++++--- 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 843d5ef60..712fde409 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -18,6 +18,7 @@ class BaseContext(dict): "actor_team_id", "actor_user_id", "channel_id", + "ts", "thread_ts", "response_url", "matches", @@ -110,6 +111,11 @@ def channel_id(self) -> Optional[str]: """The conversation ID associated with this request.""" return self.get("channel_id") + @property + def ts(self) -> Optional[str]: + """The message timestamp associated with this request.""" + return self.get("ts") + @property def thread_ts(self) -> Optional[str]: """The conversation thread's ID associated with this request.""" diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index e43cd0c27..178fb8d60 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -89,10 +89,18 @@ def build_async_required_kwargs( if "agent" in required_arg_names: from slack_bolt.agent.async_agent import AsyncBoltAgent + # For thread_ts, we check multiple sources: + # 1. context.thread_ts - populated for assistant events + # 2. event.thread_ts - for non-assistant events in a thread (e.g., app_mention in thread) + # 3. context.ts - fallback to the message timestamp + # We read from event directly to avoid changing context.thread_ts which would affect say() behavior + event = request.body.get("event", {}) + thread_ts = request.context.thread_ts or event.get("thread_ts") or request.context.ts + all_available_args["agent"] = AsyncBoltAgent( client=request.context.client, channel_id=request.context.channel_id, - thread_ts=request.context.thread_ts, + thread_ts=thread_ts, team_id=request.context.team_id, user_id=request.context.user_id, ) diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 73fe99bba..79c082e2d 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -88,10 +88,18 @@ def build_required_kwargs( if "agent" in required_arg_names: from slack_bolt.agent.agent import BoltAgent + # For thread_ts, we check multiple sources: + # 1. context.thread_ts - populated for assistant events + # 2. event.thread_ts - for non-assistant events in a thread (e.g., app_mention in thread) + # 3. context.ts - fallback to the message timestamp + # We read from event directly to avoid changing context.thread_ts which would affect say() behavior + event = request.body.get("event", {}) + thread_ts = request.context.thread_ts or event.get("thread_ts") or request.context.ts + all_available_args["agent"] = BoltAgent( client=request.context.client, channel_id=request.context.channel_id, - thread_ts=request.context.thread_ts, + thread_ts=thread_ts, team_id=request.context.team_id, user_id=request.context.user_id, ) diff --git a/slack_bolt/request/async_internals.py b/slack_bolt/request/async_internals.py index ea94739e8..ec16e84bb 100644 --- a/slack_bolt/request/async_internals.py +++ b/slack_bolt/request/async_internals.py @@ -15,6 +15,7 @@ extract_actor_team_id, extract_actor_user_id, extract_thread_ts, + extract_ts, ) @@ -45,6 +46,9 @@ def build_async_context( channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id + ts = extract_ts(body) + if ts: + context["ts"] = ts thread_ts = extract_thread_ts(body) if thread_ts: context["thread_ts"] = thread_ts diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 014a8134a..4f2e2a5a6 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -215,9 +215,14 @@ def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: def extract_thread_ts(payload: Dict[str, Any]) -> Optional[str]: - # This utility initially supports only the use cases for AI assistants, but it may be fine to add more patterns. - # That said, note that thread_ts is always required for assistant threads, but it's not for channels. - # Thus, blindly setting this thread_ts to say utility can break existing apps' behaviors. + # This utility only extracts thread_ts for assistant events to avoid breaking existing say() behavior. + # For non-assistant events, thread_ts is intentionally NOT extracted into context because: + # - say() uses context.thread_ts to decide where to post messages + # - Existing apps may expect say() to post to the channel, not the thread + # - Changing this would be a breaking change for existing apps + # + # The BoltAgent class handles non-assistant thread_ts separately by reading from the event directly, + # allowing it to work correctly without affecting say() behavior. if is_assistant_event(payload): event = payload["event"] if ( @@ -242,6 +247,18 @@ def extract_thread_ts(payload: Dict[str, Any]) -> Optional[str]: return None +def extract_ts(payload: Dict[str, Any]) -> Optional[str]: + """Extract the message timestamp from an event payload.""" + event = payload.get("event", {}) + # Direct ts on the event (e.g., app_mention, message) + if event.get("ts") is not None: + return event["ts"] + # message_changed events have ts in the message + if event.get("message", {}).get("ts") is not None: + return event["message"]["ts"] + return None + + def extract_function_execution_id(payload: Dict[str, Any]) -> Optional[str]: if payload.get("function_execution_id") is not None: return payload.get("function_execution_id") @@ -292,6 +309,9 @@ def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext: channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id + ts = extract_ts(body) + if ts: + context["ts"] = ts thread_ts = extract_thread_ts(body) if thread_ts: context["thread_ts"] = thread_ts From 2cbb1af387fe2f02426e36f3b7bd3700272f759b Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 16 Feb 2026 13:14:05 -0800 Subject: [PATCH 2/6] test: confirm agent gathers correct thread ts --- tests/scenario_tests/test_events_agent.py | 60 ++++++++++++++++++ .../scenario_tests_async/test_events_agent.py | 62 +++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/tests/scenario_tests/test_events_agent.py b/tests/scenario_tests/test_events_agent.py index 667739728..645a1266c 100644 --- a/tests/scenario_tests/test_events_agent.py +++ b/tests/scenario_tests/test_events_agent.py @@ -80,6 +80,54 @@ def handle_action(ack, agent: BoltAgent): assert response.status == 200 assert_target_called() + def test_agent_thread_ts_from_event_in_thread(self): + """Agent gets thread_ts from event when in a thread.""" + app = App(client=self.web_client) + + state = {"thread_ts": None} + + def assert_target_called(): + count = 0 + while state["thread_ts"] is None and count < 20: + sleep(0.1) + count += 1 + assert state["thread_ts"] is not None + + @app.event("app_mention") + def handle_mention(agent: BoltAgent): + state["thread_ts"] = agent._thread_ts + + request = BoltRequest(body=app_mention_in_thread_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + # Should use event.thread_ts (the thread root), not event.ts + assert state["thread_ts"] == "1111111111.111111" + + def test_agent_thread_ts_falls_back_to_ts(self): + """Agent falls back to event.ts when not in a thread.""" + app = App(client=self.web_client) + + state = {"thread_ts": None} + + def assert_target_called(): + count = 0 + while state["thread_ts"] is None and count < 20: + sleep(0.1) + count += 1 + assert state["thread_ts"] is not None + + @app.event("app_mention") + def handle_mention(agent: BoltAgent): + state["thread_ts"] = agent._thread_ts + + request = BoltRequest(body=app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + # Should fall back to event.ts since no thread_ts + assert state["thread_ts"] == "1234567890.123456" + def test_agent_kwarg_emits_experimental_warning(self): app = App(client=self.web_client) @@ -140,6 +188,18 @@ def build_payload(event: dict) -> dict: } ) +app_mention_in_thread_body = build_payload( + { + "type": "app_mention", + "user": "W222", + "text": "<@W111> hello in thread", + "ts": "2222222222.222222", + "thread_ts": "1111111111.111111", # Thread root timestamp + "channel": "C111", + "event_ts": "2222222222.222222", + } +) + action_event_body = { "type": "block_actions", "user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"}, diff --git a/tests/scenario_tests_async/test_events_agent.py b/tests/scenario_tests_async/test_events_agent.py index 1702cdb61..142934af8 100644 --- a/tests/scenario_tests_async/test_events_agent.py +++ b/tests/scenario_tests_async/test_events_agent.py @@ -86,6 +86,56 @@ async def handle_action(ack, agent: AsyncBoltAgent): assert response.status == 200 await assert_target_called() + @pytest.mark.asyncio + async def test_agent_thread_ts_from_event_in_thread(self): + """Agent gets thread_ts from event when in a thread.""" + app = AsyncApp(client=self.web_client) + + state = {"thread_ts": None} + + async def assert_target_called(): + count = 0 + while state["thread_ts"] is None and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["thread_ts"] is not None + + @app.event("app_mention") + async def handle_mention(agent: AsyncBoltAgent): + state["thread_ts"] = agent._thread_ts + + request = AsyncBoltRequest(body=app_mention_in_thread_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + # Should use event.thread_ts (the thread root), not event.ts + assert state["thread_ts"] == "1111111111.111111" + + @pytest.mark.asyncio + async def test_agent_thread_ts_falls_back_to_ts(self): + """Agent falls back to event.ts when not in a thread.""" + app = AsyncApp(client=self.web_client) + + state = {"thread_ts": None} + + async def assert_target_called(): + count = 0 + while state["thread_ts"] is None and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["thread_ts"] is not None + + @app.event("app_mention") + async def handle_mention(agent: AsyncBoltAgent): + state["thread_ts"] = agent._thread_ts + + request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + # Should fall back to event.ts since no thread_ts + assert state["thread_ts"] == "1234567890.123456" + @pytest.mark.asyncio async def test_agent_kwarg_emits_experimental_warning(self): app = AsyncApp(client=self.web_client) @@ -147,6 +197,18 @@ def build_payload(event: dict) -> dict: } ) +app_mention_in_thread_body = build_payload( + { + "type": "app_mention", + "user": "W222", + "text": "<@W111> hello in thread", + "ts": "2222222222.222222", + "thread_ts": "1111111111.111111", # Thread root timestamp + "channel": "C111", + "event_ts": "2222222222.222222", + } +) + action_event_body = { "type": "block_actions", "user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"}, From 38d0c428c74243573893cf4357aeb9678bd54503 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 16 Feb 2026 13:18:37 -0800 Subject: [PATCH 3/6] style: keep original comment with possibilities of revisiting --- slack_bolt/request/internals.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 4f2e2a5a6..9e85d865d 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -215,11 +215,9 @@ def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: def extract_thread_ts(payload: Dict[str, Any]) -> Optional[str]: - # This utility only extracts thread_ts for assistant events to avoid breaking existing say() behavior. - # For non-assistant events, thread_ts is intentionally NOT extracted into context because: - # - say() uses context.thread_ts to decide where to post messages - # - Existing apps may expect say() to post to the channel, not the thread - # - Changing this would be a breaking change for existing apps + # This utility initially supports only the use cases for AI assistants, but it may be fine to add more patterns. + # That said, note that thread_ts is always required for assistant threads, but it's not for channels. + # Thus, blindly setting this thread_ts to say utility can break existing apps' behaviors. # # The BoltAgent class handles non-assistant thread_ts separately by reading from the event directly, # allowing it to work correctly without affecting say() behavior. From f2f084e9bf60b752da889e18b79b6af016e06598 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 16 Feb 2026 13:40:10 -0800 Subject: [PATCH 4/6] refactor: avoid adding to context in this changeset --- slack_bolt/context/base_context.py | 6 ------ slack_bolt/kwargs_injection/async_utils.py | 8 ++------ slack_bolt/kwargs_injection/utils.py | 8 ++------ slack_bolt/request/async_internals.py | 4 ---- slack_bolt/request/internals.py | 15 --------------- 5 files changed, 4 insertions(+), 37 deletions(-) diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 712fde409..843d5ef60 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -18,7 +18,6 @@ class BaseContext(dict): "actor_team_id", "actor_user_id", "channel_id", - "ts", "thread_ts", "response_url", "matches", @@ -111,11 +110,6 @@ def channel_id(self) -> Optional[str]: """The conversation ID associated with this request.""" return self.get("channel_id") - @property - def ts(self) -> Optional[str]: - """The message timestamp associated with this request.""" - return self.get("ts") - @property def thread_ts(self) -> Optional[str]: """The conversation thread's ID associated with this request.""" diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index 178fb8d60..c6e80d712 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -89,13 +89,9 @@ def build_async_required_kwargs( if "agent" in required_arg_names: from slack_bolt.agent.async_agent import AsyncBoltAgent - # For thread_ts, we check multiple sources: - # 1. context.thread_ts - populated for assistant events - # 2. event.thread_ts - for non-assistant events in a thread (e.g., app_mention in thread) - # 3. context.ts - fallback to the message timestamp - # We read from event directly to avoid changing context.thread_ts which would affect say() behavior + # Resolve thread_ts: assistant events set context.thread_ts, otherwise read from event event = request.body.get("event", {}) - thread_ts = request.context.thread_ts or event.get("thread_ts") or request.context.ts + thread_ts = request.context.thread_ts or event.get("thread_ts") or event.get("ts") all_available_args["agent"] = AsyncBoltAgent( client=request.context.client, diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 79c082e2d..cbf3b0075 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -88,13 +88,9 @@ def build_required_kwargs( if "agent" in required_arg_names: from slack_bolt.agent.agent import BoltAgent - # For thread_ts, we check multiple sources: - # 1. context.thread_ts - populated for assistant events - # 2. event.thread_ts - for non-assistant events in a thread (e.g., app_mention in thread) - # 3. context.ts - fallback to the message timestamp - # We read from event directly to avoid changing context.thread_ts which would affect say() behavior + # Resolve thread_ts: assistant events set context.thread_ts, otherwise read from event event = request.body.get("event", {}) - thread_ts = request.context.thread_ts or event.get("thread_ts") or request.context.ts + thread_ts = request.context.thread_ts or event.get("thread_ts") or event.get("ts") all_available_args["agent"] = BoltAgent( client=request.context.client, diff --git a/slack_bolt/request/async_internals.py b/slack_bolt/request/async_internals.py index ec16e84bb..ea94739e8 100644 --- a/slack_bolt/request/async_internals.py +++ b/slack_bolt/request/async_internals.py @@ -15,7 +15,6 @@ extract_actor_team_id, extract_actor_user_id, extract_thread_ts, - extract_ts, ) @@ -46,9 +45,6 @@ def build_async_context( channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id - ts = extract_ts(body) - if ts: - context["ts"] = ts thread_ts = extract_thread_ts(body) if thread_ts: context["thread_ts"] = thread_ts diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 9e85d865d..e6a32db0d 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -245,18 +245,6 @@ def extract_thread_ts(payload: Dict[str, Any]) -> Optional[str]: return None -def extract_ts(payload: Dict[str, Any]) -> Optional[str]: - """Extract the message timestamp from an event payload.""" - event = payload.get("event", {}) - # Direct ts on the event (e.g., app_mention, message) - if event.get("ts") is not None: - return event["ts"] - # message_changed events have ts in the message - if event.get("message", {}).get("ts") is not None: - return event["message"]["ts"] - return None - - def extract_function_execution_id(payload: Dict[str, Any]) -> Optional[str]: if payload.get("function_execution_id") is not None: return payload.get("function_execution_id") @@ -307,9 +295,6 @@ def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext: channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id - ts = extract_ts(body) - if ts: - context["ts"] = ts thread_ts = extract_thread_ts(body) if thread_ts: context["thread_ts"] = thread_ts From 2c664c1949c9cb190b06e47aad76c11299f3876f Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 16 Feb 2026 14:03:37 -0800 Subject: [PATCH 5/6] fix: typecheck --- slack_bolt/agent/async_agent.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/slack_bolt/agent/async_agent.py b/slack_bolt/agent/async_agent.py index 8e97eae86..5630e1b81 100644 --- a/slack_bolt/agent/async_agent.py +++ b/slack_bolt/agent/async_agent.py @@ -1,7 +1,6 @@ from typing import Dict, List, Optional, Sequence, Union -from slack_sdk.web import SlackResponse -from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient from slack_sdk.web.async_chat_stream import AsyncChatStream @@ -78,7 +77,7 @@ async def set_status( channel: Optional[str] = None, thread_ts: Optional[str] = None, **kwargs, - ) -> SlackResponse: + ) -> AsyncSlackResponse: """Sets the status of an assistant thread. Args: @@ -89,7 +88,7 @@ async def set_status( **kwargs: Additional arguments passed to ``AsyncWebClient.assistant_threads_setStatus()``. Returns: - ``SlackResponse`` from the API call. + ``AsyncSlackResponse`` from the API call. """ return await self._client.assistant_threads_setStatus( channel_id=channel or self._channel_id, # type: ignore[arg-type] @@ -107,7 +106,7 @@ async def set_suggested_prompts( channel: Optional[str] = None, thread_ts: Optional[str] = None, **kwargs, - ) -> SlackResponse: + ) -> AsyncSlackResponse: """Sets suggested prompts for an assistant thread. Args: @@ -119,7 +118,7 @@ async def set_suggested_prompts( **kwargs: Additional arguments passed to ``AsyncWebClient.assistant_threads_setSuggestedPrompts()``. Returns: - ``SlackResponse`` from the API call. + ``AsyncSlackResponse`` from the API call. """ prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: From 595a1d77e1daeed0f7e7bbe0a48cc2e5aab75cb0 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 17 Feb 2026 15:39:33 -0800 Subject: [PATCH 6/6] refactor: pass a separate ts for fallback behavior in kwargs injection --- slack_bolt/agent/agent.py | 11 ++-- slack_bolt/agent/async_agent.py | 8 ++- slack_bolt/kwargs_injection/async_utils.py | 5 +- slack_bolt/kwargs_injection/utils.py | 5 +- tests/scenario_tests/test_events_agent.py | 60 ------------------ .../scenario_tests_async/test_events_agent.py | 62 ------------------- tests/slack_bolt/agent/test_agent.py | 45 ++++++++++++++ .../agent/test_async_agent.py | 47 ++++++++++++++ 8 files changed, 106 insertions(+), 137 deletions(-) diff --git a/slack_bolt/agent/agent.py b/slack_bolt/agent/agent.py index 056dba986..aa84bae90 100644 --- a/slack_bolt/agent/agent.py +++ b/slack_bolt/agent/agent.py @@ -11,9 +11,6 @@ class BoltAgent: Experimental: This API is experimental and may change in future releases. - FIXME: chat_stream() only works when thread_ts is available (DMs and threaded replies). - It does not work on channel messages because ts is not provided to BoltAgent yet. - @app.event("app_mention") def handle_mention(agent): stream = agent.chat_stream() @@ -27,12 +24,14 @@ def __init__( client: WebClient, channel_id: Optional[str] = None, thread_ts: Optional[str] = None, + 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._ts = ts self._team_id = team_id self._user_id = user_id @@ -67,7 +66,7 @@ def chat_stream( # Argument validation is delegated to chat_stream() and the API return self._client.chat_stream( channel=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] recipient_team_id=recipient_team_id or self._team_id, recipient_user_id=recipient_user_id or self._user_id, **kwargs, @@ -96,7 +95,7 @@ def set_status( """ return self._client.assistant_threads_setStatus( channel_id=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] status=status, loading_messages=loading_messages, **kwargs, @@ -133,7 +132,7 @@ def set_suggested_prompts( return self._client.assistant_threads_setSuggestedPrompts( channel_id=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] prompts=prompts_arg, title=title, **kwargs, diff --git a/slack_bolt/agent/async_agent.py b/slack_bolt/agent/async_agent.py index 5630e1b81..7272338e1 100644 --- a/slack_bolt/agent/async_agent.py +++ b/slack_bolt/agent/async_agent.py @@ -23,12 +23,14 @@ def __init__( client: AsyncWebClient, channel_id: Optional[str] = None, thread_ts: Optional[str] = None, + 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._ts = ts self._team_id = team_id self._user_id = user_id @@ -63,7 +65,7 @@ async def chat_stream( # Argument validation is delegated to chat_stream() and the API return await self._client.chat_stream( channel=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] recipient_team_id=recipient_team_id or self._team_id, recipient_user_id=recipient_user_id or self._user_id, **kwargs, @@ -92,7 +94,7 @@ async def set_status( """ return await self._client.assistant_threads_setStatus( channel_id=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] status=status, loading_messages=loading_messages, **kwargs, @@ -129,7 +131,7 @@ async def set_suggested_prompts( return await self._client.assistant_threads_setSuggestedPrompts( channel_id=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] prompts=prompts_arg, title=title, **kwargs, diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index c6e80d712..aa84b2d11 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -89,14 +89,13 @@ def build_async_required_kwargs( if "agent" in required_arg_names: from slack_bolt.agent.async_agent import AsyncBoltAgent - # Resolve thread_ts: assistant events set context.thread_ts, otherwise read from event event = request.body.get("event", {}) - thread_ts = request.context.thread_ts or event.get("thread_ts") or event.get("ts") all_available_args["agent"] = AsyncBoltAgent( client=request.context.client, channel_id=request.context.channel_id, - thread_ts=thread_ts, + thread_ts=request.context.thread_ts or event.get("thread_ts"), + ts=event.get("ts"), team_id=request.context.team_id, user_id=request.context.user_id, ) diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index cbf3b0075..5cd410a07 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -88,14 +88,13 @@ def build_required_kwargs( if "agent" in required_arg_names: from slack_bolt.agent.agent import BoltAgent - # Resolve thread_ts: assistant events set context.thread_ts, otherwise read from event event = request.body.get("event", {}) - thread_ts = request.context.thread_ts or event.get("thread_ts") or event.get("ts") all_available_args["agent"] = BoltAgent( client=request.context.client, channel_id=request.context.channel_id, - thread_ts=thread_ts, + thread_ts=request.context.thread_ts or event.get("thread_ts"), + ts=event.get("ts"), team_id=request.context.team_id, user_id=request.context.user_id, ) diff --git a/tests/scenario_tests/test_events_agent.py b/tests/scenario_tests/test_events_agent.py index 645a1266c..667739728 100644 --- a/tests/scenario_tests/test_events_agent.py +++ b/tests/scenario_tests/test_events_agent.py @@ -80,54 +80,6 @@ def handle_action(ack, agent: BoltAgent): assert response.status == 200 assert_target_called() - def test_agent_thread_ts_from_event_in_thread(self): - """Agent gets thread_ts from event when in a thread.""" - app = App(client=self.web_client) - - state = {"thread_ts": None} - - def assert_target_called(): - count = 0 - while state["thread_ts"] is None and count < 20: - sleep(0.1) - count += 1 - assert state["thread_ts"] is not None - - @app.event("app_mention") - def handle_mention(agent: BoltAgent): - state["thread_ts"] = agent._thread_ts - - request = BoltRequest(body=app_mention_in_thread_body, mode="socket_mode") - response = app.dispatch(request) - assert response.status == 200 - assert_target_called() - # Should use event.thread_ts (the thread root), not event.ts - assert state["thread_ts"] == "1111111111.111111" - - def test_agent_thread_ts_falls_back_to_ts(self): - """Agent falls back to event.ts when not in a thread.""" - app = App(client=self.web_client) - - state = {"thread_ts": None} - - def assert_target_called(): - count = 0 - while state["thread_ts"] is None and count < 20: - sleep(0.1) - count += 1 - assert state["thread_ts"] is not None - - @app.event("app_mention") - def handle_mention(agent: BoltAgent): - state["thread_ts"] = agent._thread_ts - - request = BoltRequest(body=app_mention_event_body, mode="socket_mode") - response = app.dispatch(request) - assert response.status == 200 - assert_target_called() - # Should fall back to event.ts since no thread_ts - assert state["thread_ts"] == "1234567890.123456" - def test_agent_kwarg_emits_experimental_warning(self): app = App(client=self.web_client) @@ -188,18 +140,6 @@ def build_payload(event: dict) -> dict: } ) -app_mention_in_thread_body = build_payload( - { - "type": "app_mention", - "user": "W222", - "text": "<@W111> hello in thread", - "ts": "2222222222.222222", - "thread_ts": "1111111111.111111", # Thread root timestamp - "channel": "C111", - "event_ts": "2222222222.222222", - } -) - action_event_body = { "type": "block_actions", "user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"}, diff --git a/tests/scenario_tests_async/test_events_agent.py b/tests/scenario_tests_async/test_events_agent.py index 142934af8..1702cdb61 100644 --- a/tests/scenario_tests_async/test_events_agent.py +++ b/tests/scenario_tests_async/test_events_agent.py @@ -86,56 +86,6 @@ async def handle_action(ack, agent: AsyncBoltAgent): assert response.status == 200 await assert_target_called() - @pytest.mark.asyncio - async def test_agent_thread_ts_from_event_in_thread(self): - """Agent gets thread_ts from event when in a thread.""" - app = AsyncApp(client=self.web_client) - - state = {"thread_ts": None} - - async def assert_target_called(): - count = 0 - while state["thread_ts"] is None and count < 20: - await asyncio.sleep(0.1) - count += 1 - assert state["thread_ts"] is not None - - @app.event("app_mention") - async def handle_mention(agent: AsyncBoltAgent): - state["thread_ts"] = agent._thread_ts - - request = AsyncBoltRequest(body=app_mention_in_thread_body, mode="socket_mode") - response = await app.async_dispatch(request) - assert response.status == 200 - await assert_target_called() - # Should use event.thread_ts (the thread root), not event.ts - assert state["thread_ts"] == "1111111111.111111" - - @pytest.mark.asyncio - async def test_agent_thread_ts_falls_back_to_ts(self): - """Agent falls back to event.ts when not in a thread.""" - app = AsyncApp(client=self.web_client) - - state = {"thread_ts": None} - - async def assert_target_called(): - count = 0 - while state["thread_ts"] is None and count < 20: - await asyncio.sleep(0.1) - count += 1 - assert state["thread_ts"] is not None - - @app.event("app_mention") - async def handle_mention(agent: AsyncBoltAgent): - state["thread_ts"] = agent._thread_ts - - request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") - response = await app.async_dispatch(request) - assert response.status == 200 - await assert_target_called() - # Should fall back to event.ts since no thread_ts - assert state["thread_ts"] == "1234567890.123456" - @pytest.mark.asyncio async def test_agent_kwarg_emits_experimental_warning(self): app = AsyncApp(client=self.web_client) @@ -197,18 +147,6 @@ def build_payload(event: dict) -> dict: } ) -app_mention_in_thread_body = build_payload( - { - "type": "app_mention", - "user": "W222", - "text": "<@W111> hello in thread", - "ts": "2222222222.222222", - "thread_ts": "1111111111.111111", # Thread root timestamp - "channel": "C111", - "event_ts": "2222222222.222222", - } -) - action_event_body = { "type": "block_actions", "user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"}, diff --git a/tests/slack_bolt/agent/test_agent.py b/tests/slack_bolt/agent/test_agent.py index 1d14eda06..87d51d9eb 100644 --- a/tests/slack_bolt/agent/test_agent.py +++ b/tests/slack_bolt/agent/test_agent.py @@ -92,6 +92,51 @@ def test_chat_stream_passes_extra_kwargs(self): buffer_size=512, ) + def test_chat_stream_falls_back_to_ts(self): + """When thread_ts is not set, chat_stream() falls back to ts.""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgent( + client=client, + channel_id="C111", + team_id="T111", + ts="1111111111.111111", + user_id="W222", + ) + stream = agent.chat_stream() + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1111111111.111111", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + + def test_chat_stream_prefers_thread_ts_over_ts(self): + """thread_ts takes priority over ts.""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgent( + client=client, + channel_id="C111", + team_id="T111", + thread_ts="1234567890.123456", + ts="1111111111.111111", + user_id="W222", + ) + stream = agent.chat_stream() + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + def test_set_status_uses_context_defaults(self): """BoltAgent.set_status() passes context defaults to WebClient.assistant_threads_setStatus().""" client = MagicMock(spec=WebClient) diff --git a/tests/slack_bolt_async/agent/test_async_agent.py b/tests/slack_bolt_async/agent/test_async_agent.py index b934bbaeb..7c01a4301 100644 --- a/tests/slack_bolt_async/agent/test_async_agent.py +++ b/tests/slack_bolt_async/agent/test_async_agent.py @@ -118,6 +118,53 @@ async def test_chat_stream_passes_extra_kwargs(self): buffer_size=512, ) + @pytest.mark.asyncio + async def test_chat_stream_falls_back_to_ts(self): + """When thread_ts is not set, chat_stream() falls back to ts.""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + team_id="T111", + ts="1111111111.111111", + user_id="W222", + ) + stream = await agent.chat_stream() + + call_tracker.assert_called_once_with( + channel="C111", + thread_ts="1111111111.111111", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + + @pytest.mark.asyncio + async def test_chat_stream_prefers_thread_ts_over_ts(self): + """thread_ts takes priority over ts.""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + team_id="T111", + thread_ts="1234567890.123456", + ts="1111111111.111111", + user_id="W222", + ) + stream = await agent.chat_stream() + + call_tracker.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + @pytest.mark.asyncio async def test_set_status_uses_context_defaults(self): """AsyncBoltAgent.set_status() passes context defaults to AsyncWebClient.assistant_threads_setStatus()."""