Skip to content

Commit 254e734

Browse files
fix(slack): polish trigger response streaming
1 parent 8d6199b commit 254e734

6 files changed

Lines changed: 157 additions & 29 deletions

File tree

apps/docs/content/docs/integrations/slack.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ In Sim, the Slack integration enables your agents to programmatically interact w
3232

3333
Custom-bot Slack triggers can stream workflow outputs directly back into the conversation that started a run. Enable **Stream response to Slack** on a Message, App Mention, or Assistant Thread Started trigger, then select the outputs to deliver.
3434

35-
- A selected Agent output streams as it is generated. Intermediate pre-tool turns are held back so only the final answer is appended.
35+
- A selected Agent output streams immediately as it is generated. If the Agent later calls a tool, any pre-tool commentary already streamed remains visible.
3636
- A selected non-streaming block output is sent when that block invocation completes.
3737
- Loop and parallel invocations each create their own Slack response.
38+
- The response status label defaults to `Running` and can be customized in the trigger's advanced settings.
3839
- Optional thinking and tool-call updates appear as Slack tasks in a timeline or plan.
3940
- Slack Agent Sessions remain in processing state for the run, return to active when it finishes, and the native Slack stop button cancels active workflow executions.
4041

@@ -2007,6 +2008,7 @@ Trigger from Slack events (mentions, messages, reactions)
20072008
| `streamResponse` | boolean | No | Stream selected workflow outputs into the Slack conversation that started this run. Custom bots only. |
20082009
| `streamOutputs` | workflow-output-selector | No | Each selected block invocation creates its own Slack response. Agent outputs stream live; other outputs are sent when the block completes. |
20092010
| `streamIncludeThinking` | boolean | No | Show agent thinking as Slack task updates while the response is generated. |
2011+
| `streamTaskTitle` | string | No | The status Slack shows while each selected response is being produced. |
20102012
| `streamIncludeToolCalls` | boolean | No | Show tool execution lifecycle as Slack task updates. |
20112013
| `streamTaskDisplayMode` | string | No | Choose how Slack displays thinking and tool progress. |
20122014
| `source` | string | No | Restrict to direct messages, public channels, or private channels. Leave empty to match any. |

apps/sim/lib/webhooks/slack-execution-stream.test.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const BASE_CONFIG: SlackStreamResponseConfig = {
4848
outputConfigs: [{ blockId: 'agent', path: 'content' }],
4949
includeThinking: true,
5050
includeToolCalls: true,
51+
taskTitle: 'Running',
5152
taskDisplayMode: 'plan',
5253
}
5354

@@ -67,6 +68,22 @@ function createByteStream(text = ''): ReadableStream<Uint8Array> {
6768
})
6869
}
6970

71+
function createOpenByteStream(): { stream: ReadableStream<Uint8Array>; close: () => void } {
72+
let closeStream: (() => void) | undefined
73+
const stream = new ReadableStream<Uint8Array>({
74+
start(controller) {
75+
closeStream = () => controller.close()
76+
},
77+
})
78+
return {
79+
stream,
80+
close: () => {
81+
if (!closeStream) throw new Error('Test stream was not initialized')
82+
closeStream()
83+
},
84+
}
85+
}
86+
7087
async function createController(
7188
config: SlackStreamResponseConfig = BASE_CONFIG,
7289
triggerInput: Record<string, unknown> = {
@@ -106,8 +123,8 @@ describe('SlackExecutionStreamController', () => {
106123
const { controller } = await createController()
107124
const events: AgentStreamEvent[] = [
108125
{ type: 'thinking_delta', text: 'Checking context' },
109-
{ type: 'tool_call_start', id: 'tool-1', name: 'Search' },
110-
{ type: 'tool_call_end', id: 'tool-1', name: 'Search', status: 'success' },
126+
{ type: 'tool_call_start', id: 'tool-1', name: 'slack_send_message' },
127+
{ type: 'tool_call_end', id: 'tool-1', name: 'slack_send_message', status: 'success' },
111128
{ type: 'text_delta', text: 'Hello ', turn: 'pending' },
112129
{ type: 'text_delta', text: 'world', turn: 'pending' },
113130
{ type: 'turn_end', turn: 'final' },
@@ -153,7 +170,7 @@ describe('SlackExecutionStreamController', () => {
153170
{
154171
type: 'task_update',
155172
id: 'sim-execution-1-4',
156-
title: 'Generating agent',
173+
title: 'Running',
157174
status: 'in_progress',
158175
},
159176
],
@@ -166,11 +183,16 @@ describe('SlackExecutionStreamController', () => {
166183
expect.objectContaining({ type: 'task_update', title: 'Thinking', status: 'complete' }),
167184
expect.objectContaining({
168185
type: 'task_update',
169-
title: 'Search',
186+
title: 'Slack Send Message',
170187
status: 'in_progress',
171188
}),
172-
expect.objectContaining({ type: 'task_update', title: 'Search', status: 'complete' }),
173-
{ type: 'markdown_text', text: 'Hello world' },
189+
expect.objectContaining({
190+
type: 'task_update',
191+
title: 'Slack Send Message',
192+
status: 'complete',
193+
}),
194+
{ type: 'markdown_text', text: 'Hello ' },
195+
{ type: 'markdown_text', text: 'world' },
174196
expect.objectContaining({
175197
type: 'task_update',
176198
id: 'sim-execution-1-4',
@@ -211,6 +233,36 @@ describe('SlackExecutionStreamController', () => {
211233
)
212234
})
213235

236+
it('appends pending answer text before the model turn is classified', async () => {
237+
const { controller } = await createController()
238+
const { stream, close } = createOpenByteStream()
239+
const streaming = controller.callbacks.onStream?.({
240+
blockId: 'agent',
241+
executionOrder: 5,
242+
stream,
243+
streamFormat: 'text',
244+
clientStreamTransformed: false,
245+
subscribe: ({ onEvent }) => {
246+
void onEvent({ type: 'text_delta', text: 'Once upon a time', turn: 'pending' })
247+
return vi.fn()
248+
},
249+
})
250+
251+
await vi.waitFor(() => {
252+
expect(mockAppendSlackAgentStream).toHaveBeenCalledWith(
253+
'xoxb-token',
254+
'C123',
255+
'1700000001.000002',
256+
[{ type: 'markdown_text', text: 'Once upon a time' }],
257+
undefined
258+
)
259+
})
260+
expect(mockStopSlackAgentStream).not.toHaveBeenCalled()
261+
262+
close()
263+
await streaming
264+
})
265+
214266
it('sends selected non-streaming outputs after block completion', async () => {
215267
const config: SlackStreamResponseConfig = {
216268
...BASE_CONFIG,

apps/sim/lib/webhooks/slack-execution-stream.ts

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getErrorMessage } from '@sim/utils/errors'
22
import { isRecordLike } from '@sim/utils/object'
33
import { truncate } from '@sim/utils/string'
4+
import { humanizeToolName } from '@/lib/copilot/tools/tool-display'
45
import type { LoggingSession } from '@/lib/logs/execution/logging-session'
56
import { getSlackBotCredential } from '@/lib/oauth/credential-service'
67
import { pluckByPath } from '@/lib/table/pluck'
@@ -25,7 +26,7 @@ import type { BlockCompletionCallbackData, ExecutionCallbacks } from '@/executor
2526
import type { ExecutionResult, StreamingExecution } from '@/executor/types'
2627
import type { AgentStreamEvent } from '@/providers/stream-events'
2728

28-
const TEXT_FLUSH_SIZE = 512
29+
const TEXT_FLUSH_SIZE = 128
2930
const SLACK_MARKDOWN_LIMIT = 12_000
3031
const TASK_TEXT_LIMIT = 256
3132

@@ -104,7 +105,6 @@ class SlackInvocationStream {
104105
private channel?: string
105106
private ts?: string
106107
private answerBuffer = ''
107-
private pendingTurn = ''
108108
private fullAnswer = ''
109109
private thinking = ''
110110
private emittedAnswer = false
@@ -135,7 +135,7 @@ class SlackInvocationStream {
135135
{
136136
type: 'task_update',
137137
id: this.taskId,
138-
title: truncate(this.title, TASK_TEXT_LIMIT),
138+
title: this.title,
139139
status: 'in_progress',
140140
},
141141
],
@@ -166,7 +166,7 @@ class SlackInvocationStream {
166166
if (!text) return
167167
this.fullAnswer += text
168168
this.answerBuffer += text
169-
await this.flushAnswer(force)
169+
await this.flushAnswer(force || !this.emittedAnswer)
170170
}
171171

172172
private async flushThinking(): Promise<void> {
@@ -190,16 +190,13 @@ class SlackInvocationStream {
190190
return this.enqueue(async () => {
191191
switch (event.type) {
192192
case 'text_delta':
193-
if (event.turn === 'pending') {
194-
this.pendingTurn += event.text
195-
} else if (event.turn !== 'intermediate') {
193+
if (event.turn !== 'intermediate') {
196194
await this.appendAnswer(event.text)
197195
}
198196
return
199197
case 'turn_end':
200198
await this.flushThinking()
201-
if (event.turn === 'final') await this.appendAnswer(this.pendingTurn, true)
202-
this.pendingTurn = ''
199+
await this.flushAnswer(true)
203200
return
204201
case 'thinking_delta':
205202
if (this.config.includeThinking) this.thinking += event.text
@@ -211,7 +208,7 @@ class SlackInvocationStream {
211208
{
212209
type: 'task_update',
213210
id: `${this.taskId}-tool-${event.id}`,
214-
title: truncate(event.name, TASK_TEXT_LIMIT),
211+
title: truncate(humanizeToolName(event.name), TASK_TEXT_LIMIT),
215212
status: 'in_progress',
216213
},
217214
])
@@ -223,7 +220,7 @@ class SlackInvocationStream {
223220
{
224221
type: 'task_update',
225222
id: `${this.taskId}-tool-${event.id}`,
226-
title: truncate(event.name, TASK_TEXT_LIMIT),
223+
title: truncate(humanizeToolName(event.name), TASK_TEXT_LIMIT),
227224
status: event.status === 'success' ? 'complete' : 'error',
228225
},
229226
])
@@ -251,7 +248,7 @@ class SlackInvocationStream {
251248
{
252249
type: 'task_update',
253250
id: this.taskId,
254-
title: truncate(this.title, TASK_TEXT_LIMIT),
251+
title: this.title,
255252
status: 'complete',
256253
},
257254
])
@@ -267,7 +264,7 @@ class SlackInvocationStream {
267264
{
268265
type: 'task_update',
269266
id: this.taskId,
270-
title: truncate(this.title, TASK_TEXT_LIMIT),
267+
title: this.title,
271268
status: 'complete',
272269
},
273270
])
@@ -297,8 +294,8 @@ export class SlackExecutionStreamController {
297294
)
298295
this.callbacks = {
299296
onStream: (stream) => this.onStream(stream),
300-
onBlockComplete: (blockId, blockName, _blockType, data) =>
301-
this.onBlockComplete(blockId, blockName, data),
297+
onBlockComplete: (blockId, _blockName, _blockType, data) =>
298+
this.onBlockComplete(blockId, data),
302299
}
303300
}
304301

@@ -382,7 +379,7 @@ export class SlackExecutionStreamController {
382379
this.target,
383380
this.options.config,
384381
`sim-${this.options.executionId}-${stream.executionOrder}`,
385-
`Generating ${stream.blockId}`,
382+
this.options.config.taskTitle,
386383
(text) => this.projectLiveText(text, stream.displayResolvedSecretTraceProvenance),
387384
(text) => this.projectFinalText(text, stream.displayResolvedSecretTraceProvenance),
388385
this.options.abortSignal
@@ -416,11 +413,7 @@ export class SlackExecutionStreamController {
416413
}
417414
}
418415

419-
private async onBlockComplete(
420-
blockId: string,
421-
blockName: string,
422-
data: BlockCompletionCallbackData
423-
): Promise<void> {
416+
private async onBlockComplete(blockId: string, data: BlockCompletionCallbackData): Promise<void> {
424417
try {
425418
const selected = this.selectedForBlock(blockId)
426419
if (selected.length === 0) return
@@ -446,7 +439,7 @@ export class SlackExecutionStreamController {
446439
this.target,
447440
this.options.config,
448441
`sim-${this.options.executionId}-${data.executionOrder}`,
449-
blockName,
442+
this.options.config.taskTitle,
450443
async (value) => value,
451444
async (value) => value,
452445
this.options.abortSignal

apps/sim/lib/webhooks/slack-stream-config.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe('Slack stream response config', () => {
1616
streamOutputs: ['block-1_content', 'block-2_result.value'],
1717
streamIncludeThinking: true,
1818
streamIncludeToolCalls: false,
19+
streamTaskTitle: ' Working ',
1920
streamTaskDisplayMode: 'plan',
2021
}
2122
const normalized = normalizeSlackStreamResponseConfig(providerConfig)
@@ -29,11 +30,45 @@ describe('Slack stream response config', () => {
2930
],
3031
includeThinking: true,
3132
includeToolCalls: false,
33+
taskTitle: 'Working',
3234
taskDisplayMode: 'plan',
3335
})
3436
expect(readSlackStreamResponseConfig(providerConfig)).toEqual(normalized)
3537
expect(providerConfig.streamResponse).toBeUndefined()
3638
expect(providerConfig.streamOutputs).toBeUndefined()
39+
expect(providerConfig.streamTaskTitle).toBeUndefined()
40+
})
41+
42+
it('defaults the response status label to Running and rejects invalid labels', () => {
43+
expect(
44+
normalizeSlackStreamResponseConfig({
45+
eventType: 'message',
46+
streamResponse: true,
47+
streamOutputs: ['block_content'],
48+
})?.taskTitle
49+
).toBe('Running')
50+
expect(() =>
51+
normalizeSlackStreamResponseConfig({
52+
eventType: 'message',
53+
streamResponse: true,
54+
streamOutputs: ['block_content'],
55+
streamTaskTitle: ' ',
56+
})
57+
).toThrow('status label is required')
58+
})
59+
60+
it('upgrades persisted configs created before response status labels existed', () => {
61+
expect(
62+
readSlackStreamResponseConfig({
63+
streamResponseConfig: {
64+
enabled: true,
65+
outputConfigs: [{ blockId: 'block', path: 'content' }],
66+
includeThinking: false,
67+
includeToolCalls: true,
68+
taskDisplayMode: 'timeline',
69+
},
70+
})?.taskTitle
71+
).toBe('Running')
3772
})
3873

3974
it('rejects non-reply events and malformed output selectors', () => {

apps/sim/lib/webhooks/slack-stream-config.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ export interface SlackStreamResponseConfig {
1616
outputConfigs: SlackStreamOutputConfig[]
1717
includeThinking: boolean
1818
includeToolCalls: boolean
19+
taskTitle: string
1920
taskDisplayMode: 'timeline' | 'plan'
2021
}
2122

23+
const SLACK_TASK_TITLE_LIMIT = 256
24+
2225
function parseOutputSelector(selector: string): SlackStreamOutputConfig {
2326
const separatorIndex = selector.indexOf('_')
2427
if (separatorIndex <= 0 || separatorIndex === selector.length - 1) {
@@ -57,12 +60,26 @@ export function normalizeSlackStreamResponseConfig(
5760
if (taskDisplayMode !== 'timeline' && taskDisplayMode !== 'plan') {
5861
throw new Error('Slack stream task display mode must be timeline or plan')
5962
}
63+
const rawTaskTitle = providerConfig.streamTaskTitle ?? 'Running'
64+
if (typeof rawTaskTitle !== 'string') {
65+
throw new Error('Slack stream response status label must be a string')
66+
}
67+
const taskTitle = rawTaskTitle.trim()
68+
if (!taskTitle) {
69+
throw new Error('Slack stream response status label is required')
70+
}
71+
if (taskTitle.length > SLACK_TASK_TITLE_LIMIT) {
72+
throw new Error(
73+
`Slack stream response status label must be ${SLACK_TASK_TITLE_LIMIT} characters or fewer`
74+
)
75+
}
6076

6177
return {
6278
enabled: true,
6379
outputConfigs: selectors.map(parseOutputSelector),
6480
includeThinking: providerConfig.streamIncludeThinking === true,
6581
includeToolCalls: providerConfig.streamIncludeToolCalls !== false,
82+
taskTitle,
6683
taskDisplayMode,
6784
}
6885
}
@@ -91,6 +108,13 @@ export function readSlackStreamResponseConfig(
91108
if (typeof value.includeThinking !== 'boolean' || typeof value.includeToolCalls !== 'boolean') {
92109
throw new Error('Persisted Slack stream visibility settings are invalid')
93110
}
111+
const taskTitle = value.taskTitle === undefined ? 'Running' : value.taskTitle
112+
if (typeof taskTitle !== 'string' || !taskTitle.trim()) {
113+
throw new Error('Persisted Slack stream response status label is invalid')
114+
}
115+
if (taskTitle.length > SLACK_TASK_TITLE_LIMIT) {
116+
throw new Error('Persisted Slack stream response status label is too long')
117+
}
94118
if (value.taskDisplayMode !== 'timeline' && value.taskDisplayMode !== 'plan') {
95119
throw new Error('Persisted Slack stream task display mode is invalid')
96120
}
@@ -99,6 +123,7 @@ export function readSlackStreamResponseConfig(
99123
outputConfigs,
100124
includeThinking: value.includeThinking,
101125
includeToolCalls: value.includeToolCalls,
126+
taskTitle,
102127
taskDisplayMode: value.taskDisplayMode,
103128
}
104129
}
@@ -122,5 +147,6 @@ export function replaceSlackStreamAuthoringConfig(
122147
providerConfig.streamOutputs = undefined
123148
providerConfig.streamIncludeThinking = undefined
124149
providerConfig.streamIncludeToolCalls = undefined
150+
providerConfig.streamTaskTitle = undefined
125151
providerConfig.streamTaskDisplayMode = undefined
126152
}

0 commit comments

Comments
 (0)