Skip to content

Commit 595a1d7

Browse files
committed
refactor: pass a separate ts for fallback behavior in kwargs injection
1 parent b77b5a7 commit 595a1d7

File tree

8 files changed

+106
-137
lines changed

8 files changed

+106
-137
lines changed

slack_bolt/agent/agent.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@ class BoltAgent:
1111
Experimental:
1212
This API is experimental and may change in future releases.
1313
14-
FIXME: chat_stream() only works when thread_ts is available (DMs and threaded replies).
15-
It does not work on channel messages because ts is not provided to BoltAgent yet.
16-
1714
@app.event("app_mention")
1815
def handle_mention(agent):
1916
stream = agent.chat_stream()
@@ -27,12 +24,14 @@ def __init__(
2724
client: WebClient,
2825
channel_id: Optional[str] = None,
2926
thread_ts: Optional[str] = None,
27+
ts: Optional[str] = None,
3028
team_id: Optional[str] = None,
3129
user_id: Optional[str] = None,
3230
):
3331
self._client = client
3432
self._channel_id = channel_id
3533
self._thread_ts = thread_ts
34+
self._ts = ts
3635
self._team_id = team_id
3736
self._user_id = user_id
3837

@@ -67,7 +66,7 @@ def chat_stream(
6766
# Argument validation is delegated to chat_stream() and the API
6867
return self._client.chat_stream(
6968
channel=channel or self._channel_id, # type: ignore[arg-type]
70-
thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type]
69+
thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type]
7170
recipient_team_id=recipient_team_id or self._team_id,
7271
recipient_user_id=recipient_user_id or self._user_id,
7372
**kwargs,
@@ -96,7 +95,7 @@ def set_status(
9695
"""
9796
return self._client.assistant_threads_setStatus(
9897
channel_id=channel or self._channel_id, # type: ignore[arg-type]
99-
thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type]
98+
thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type]
10099
status=status,
101100
loading_messages=loading_messages,
102101
**kwargs,
@@ -133,7 +132,7 @@ def set_suggested_prompts(
133132

134133
return self._client.assistant_threads_setSuggestedPrompts(
135134
channel_id=channel or self._channel_id, # type: ignore[arg-type]
136-
thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type]
135+
thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type]
137136
prompts=prompts_arg,
138137
title=title,
139138
**kwargs,

slack_bolt/agent/async_agent.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ def __init__(
2323
client: AsyncWebClient,
2424
channel_id: Optional[str] = None,
2525
thread_ts: Optional[str] = None,
26+
ts: Optional[str] = None,
2627
team_id: Optional[str] = None,
2728
user_id: Optional[str] = None,
2829
):
2930
self._client = client
3031
self._channel_id = channel_id
3132
self._thread_ts = thread_ts
33+
self._ts = ts
3234
self._team_id = team_id
3335
self._user_id = user_id
3436

@@ -63,7 +65,7 @@ async def chat_stream(
6365
# Argument validation is delegated to chat_stream() and the API
6466
return await self._client.chat_stream(
6567
channel=channel or self._channel_id, # type: ignore[arg-type]
66-
thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type]
68+
thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type]
6769
recipient_team_id=recipient_team_id or self._team_id,
6870
recipient_user_id=recipient_user_id or self._user_id,
6971
**kwargs,
@@ -92,7 +94,7 @@ async def set_status(
9294
"""
9395
return await self._client.assistant_threads_setStatus(
9496
channel_id=channel or self._channel_id, # type: ignore[arg-type]
95-
thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type]
97+
thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type]
9698
status=status,
9799
loading_messages=loading_messages,
98100
**kwargs,
@@ -129,7 +131,7 @@ async def set_suggested_prompts(
129131

130132
return await self._client.assistant_threads_setSuggestedPrompts(
131133
channel_id=channel or self._channel_id, # type: ignore[arg-type]
132-
thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type]
134+
thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type]
133135
prompts=prompts_arg,
134136
title=title,
135137
**kwargs,

slack_bolt/kwargs_injection/async_utils.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,13 @@ def build_async_required_kwargs(
8989
if "agent" in required_arg_names:
9090
from slack_bolt.agent.async_agent import AsyncBoltAgent
9191

92-
# Resolve thread_ts: assistant events set context.thread_ts, otherwise read from event
9392
event = request.body.get("event", {})
94-
thread_ts = request.context.thread_ts or event.get("thread_ts") or event.get("ts")
9593

9694
all_available_args["agent"] = AsyncBoltAgent(
9795
client=request.context.client,
9896
channel_id=request.context.channel_id,
99-
thread_ts=thread_ts,
97+
thread_ts=request.context.thread_ts or event.get("thread_ts"),
98+
ts=event.get("ts"),
10099
team_id=request.context.team_id,
101100
user_id=request.context.user_id,
102101
)

slack_bolt/kwargs_injection/utils.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,13 @@ def build_required_kwargs(
8888
if "agent" in required_arg_names:
8989
from slack_bolt.agent.agent import BoltAgent
9090

91-
# Resolve thread_ts: assistant events set context.thread_ts, otherwise read from event
9291
event = request.body.get("event", {})
93-
thread_ts = request.context.thread_ts or event.get("thread_ts") or event.get("ts")
9492

9593
all_available_args["agent"] = BoltAgent(
9694
client=request.context.client,
9795
channel_id=request.context.channel_id,
98-
thread_ts=thread_ts,
96+
thread_ts=request.context.thread_ts or event.get("thread_ts"),
97+
ts=event.get("ts"),
9998
team_id=request.context.team_id,
10099
user_id=request.context.user_id,
101100
)

tests/scenario_tests/test_events_agent.py

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -80,54 +80,6 @@ def handle_action(ack, agent: BoltAgent):
8080
assert response.status == 200
8181
assert_target_called()
8282

83-
def test_agent_thread_ts_from_event_in_thread(self):
84-
"""Agent gets thread_ts from event when in a thread."""
85-
app = App(client=self.web_client)
86-
87-
state = {"thread_ts": None}
88-
89-
def assert_target_called():
90-
count = 0
91-
while state["thread_ts"] is None and count < 20:
92-
sleep(0.1)
93-
count += 1
94-
assert state["thread_ts"] is not None
95-
96-
@app.event("app_mention")
97-
def handle_mention(agent: BoltAgent):
98-
state["thread_ts"] = agent._thread_ts
99-
100-
request = BoltRequest(body=app_mention_in_thread_body, mode="socket_mode")
101-
response = app.dispatch(request)
102-
assert response.status == 200
103-
assert_target_called()
104-
# Should use event.thread_ts (the thread root), not event.ts
105-
assert state["thread_ts"] == "1111111111.111111"
106-
107-
def test_agent_thread_ts_falls_back_to_ts(self):
108-
"""Agent falls back to event.ts when not in a thread."""
109-
app = App(client=self.web_client)
110-
111-
state = {"thread_ts": None}
112-
113-
def assert_target_called():
114-
count = 0
115-
while state["thread_ts"] is None and count < 20:
116-
sleep(0.1)
117-
count += 1
118-
assert state["thread_ts"] is not None
119-
120-
@app.event("app_mention")
121-
def handle_mention(agent: BoltAgent):
122-
state["thread_ts"] = agent._thread_ts
123-
124-
request = BoltRequest(body=app_mention_event_body, mode="socket_mode")
125-
response = app.dispatch(request)
126-
assert response.status == 200
127-
assert_target_called()
128-
# Should fall back to event.ts since no thread_ts
129-
assert state["thread_ts"] == "1234567890.123456"
130-
13183
def test_agent_kwarg_emits_experimental_warning(self):
13284
app = App(client=self.web_client)
13385

@@ -188,18 +140,6 @@ def build_payload(event: dict) -> dict:
188140
}
189141
)
190142

191-
app_mention_in_thread_body = build_payload(
192-
{
193-
"type": "app_mention",
194-
"user": "W222",
195-
"text": "<@W111> hello in thread",
196-
"ts": "2222222222.222222",
197-
"thread_ts": "1111111111.111111", # Thread root timestamp
198-
"channel": "C111",
199-
"event_ts": "2222222222.222222",
200-
}
201-
)
202-
203143
action_event_body = {
204144
"type": "block_actions",
205145
"user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"},

tests/scenario_tests_async/test_events_agent.py

Lines changed: 0 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -86,56 +86,6 @@ async def handle_action(ack, agent: AsyncBoltAgent):
8686
assert response.status == 200
8787
await assert_target_called()
8888

89-
@pytest.mark.asyncio
90-
async def test_agent_thread_ts_from_event_in_thread(self):
91-
"""Agent gets thread_ts from event when in a thread."""
92-
app = AsyncApp(client=self.web_client)
93-
94-
state = {"thread_ts": None}
95-
96-
async def assert_target_called():
97-
count = 0
98-
while state["thread_ts"] is None and count < 20:
99-
await asyncio.sleep(0.1)
100-
count += 1
101-
assert state["thread_ts"] is not None
102-
103-
@app.event("app_mention")
104-
async def handle_mention(agent: AsyncBoltAgent):
105-
state["thread_ts"] = agent._thread_ts
106-
107-
request = AsyncBoltRequest(body=app_mention_in_thread_body, mode="socket_mode")
108-
response = await app.async_dispatch(request)
109-
assert response.status == 200
110-
await assert_target_called()
111-
# Should use event.thread_ts (the thread root), not event.ts
112-
assert state["thread_ts"] == "1111111111.111111"
113-
114-
@pytest.mark.asyncio
115-
async def test_agent_thread_ts_falls_back_to_ts(self):
116-
"""Agent falls back to event.ts when not in a thread."""
117-
app = AsyncApp(client=self.web_client)
118-
119-
state = {"thread_ts": None}
120-
121-
async def assert_target_called():
122-
count = 0
123-
while state["thread_ts"] is None and count < 20:
124-
await asyncio.sleep(0.1)
125-
count += 1
126-
assert state["thread_ts"] is not None
127-
128-
@app.event("app_mention")
129-
async def handle_mention(agent: AsyncBoltAgent):
130-
state["thread_ts"] = agent._thread_ts
131-
132-
request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode")
133-
response = await app.async_dispatch(request)
134-
assert response.status == 200
135-
await assert_target_called()
136-
# Should fall back to event.ts since no thread_ts
137-
assert state["thread_ts"] == "1234567890.123456"
138-
13989
@pytest.mark.asyncio
14090
async def test_agent_kwarg_emits_experimental_warning(self):
14191
app = AsyncApp(client=self.web_client)
@@ -197,18 +147,6 @@ def build_payload(event: dict) -> dict:
197147
}
198148
)
199149

200-
app_mention_in_thread_body = build_payload(
201-
{
202-
"type": "app_mention",
203-
"user": "W222",
204-
"text": "<@W111> hello in thread",
205-
"ts": "2222222222.222222",
206-
"thread_ts": "1111111111.111111", # Thread root timestamp
207-
"channel": "C111",
208-
"event_ts": "2222222222.222222",
209-
}
210-
)
211-
212150
action_event_body = {
213151
"type": "block_actions",
214152
"user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"},

tests/slack_bolt/agent/test_agent.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,51 @@ def test_chat_stream_passes_extra_kwargs(self):
9292
buffer_size=512,
9393
)
9494

95+
def test_chat_stream_falls_back_to_ts(self):
96+
"""When thread_ts is not set, chat_stream() falls back to ts."""
97+
client = MagicMock(spec=WebClient)
98+
client.chat_stream.return_value = MagicMock(spec=ChatStream)
99+
100+
agent = BoltAgent(
101+
client=client,
102+
channel_id="C111",
103+
team_id="T111",
104+
ts="1111111111.111111",
105+
user_id="W222",
106+
)
107+
stream = agent.chat_stream()
108+
109+
client.chat_stream.assert_called_once_with(
110+
channel="C111",
111+
thread_ts="1111111111.111111",
112+
recipient_team_id="T111",
113+
recipient_user_id="W222",
114+
)
115+
assert stream is not None
116+
117+
def test_chat_stream_prefers_thread_ts_over_ts(self):
118+
"""thread_ts takes priority over ts."""
119+
client = MagicMock(spec=WebClient)
120+
client.chat_stream.return_value = MagicMock(spec=ChatStream)
121+
122+
agent = BoltAgent(
123+
client=client,
124+
channel_id="C111",
125+
team_id="T111",
126+
thread_ts="1234567890.123456",
127+
ts="1111111111.111111",
128+
user_id="W222",
129+
)
130+
stream = agent.chat_stream()
131+
132+
client.chat_stream.assert_called_once_with(
133+
channel="C111",
134+
thread_ts="1234567890.123456",
135+
recipient_team_id="T111",
136+
recipient_user_id="W222",
137+
)
138+
assert stream is not None
139+
95140
def test_set_status_uses_context_defaults(self):
96141
"""BoltAgent.set_status() passes context defaults to WebClient.assistant_threads_setStatus()."""
97142
client = MagicMock(spec=WebClient)

tests/slack_bolt_async/agent/test_async_agent.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,53 @@ async def test_chat_stream_passes_extra_kwargs(self):
118118
buffer_size=512,
119119
)
120120

121+
@pytest.mark.asyncio
122+
async def test_chat_stream_falls_back_to_ts(self):
123+
"""When thread_ts is not set, chat_stream() falls back to ts."""
124+
client = MagicMock(spec=AsyncWebClient)
125+
client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock()
126+
127+
agent = AsyncBoltAgent(
128+
client=client,
129+
channel_id="C111",
130+
team_id="T111",
131+
ts="1111111111.111111",
132+
user_id="W222",
133+
)
134+
stream = await agent.chat_stream()
135+
136+
call_tracker.assert_called_once_with(
137+
channel="C111",
138+
thread_ts="1111111111.111111",
139+
recipient_team_id="T111",
140+
recipient_user_id="W222",
141+
)
142+
assert stream is not None
143+
144+
@pytest.mark.asyncio
145+
async def test_chat_stream_prefers_thread_ts_over_ts(self):
146+
"""thread_ts takes priority over ts."""
147+
client = MagicMock(spec=AsyncWebClient)
148+
client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock()
149+
150+
agent = AsyncBoltAgent(
151+
client=client,
152+
channel_id="C111",
153+
team_id="T111",
154+
thread_ts="1234567890.123456",
155+
ts="1111111111.111111",
156+
user_id="W222",
157+
)
158+
stream = await agent.chat_stream()
159+
160+
call_tracker.assert_called_once_with(
161+
channel="C111",
162+
thread_ts="1234567890.123456",
163+
recipient_team_id="T111",
164+
recipient_user_id="W222",
165+
)
166+
assert stream is not None
167+
121168
@pytest.mark.asyncio
122169
async def test_set_status_uses_context_defaults(self):
123170
"""AsyncBoltAgent.set_status() passes context defaults to AsyncWebClient.assistant_threads_setStatus()."""

0 commit comments

Comments
 (0)