Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions slack_bolt/agent/async_agent.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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:
Expand All @@ -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]
Expand All @@ -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:
Expand All @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion slack_bolt/kwargs_injection/async_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,14 @@ 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=request.context.thread_ts,
thread_ts=thread_ts,
team_id=request.context.team_id,
user_id=request.context.user_id,
)
Expand Down
6 changes: 5 additions & 1 deletion slack_bolt/kwargs_injection/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,14 @@ 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")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 note: We avoided adding a new ts value to the listener context in this PR and instead use the event information. This was to keep scope changes minimal and parity with the Bolt JS implementation IIRC.


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,
)
Expand Down
3 changes: 3 additions & 0 deletions slack_bolt/request/internals.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ 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.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👁️‍🗨️ thought: I'm surprised that say posts top-level messages in response to threaded messages by default TBH!

I agree that a "fix" for this, to respond in thread if a thread_ts is present, might cause new behavior for apps but am wondering if this is intended behavior or something to ponder changing in the future?

#
# 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 (
Expand Down
60 changes: 60 additions & 0 deletions tests/scenario_tests/test_events_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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"},
Expand Down
62 changes: 62 additions & 0 deletions tests/scenario_tests_async/test_events_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"},
Expand Down