Skip to content

Commit ef5972a

Browse files
feat(slack): stream Search tool progress
1 parent f25899c commit ef5972a

5 files changed

Lines changed: 262 additions & 7 deletions

File tree

apps/sim/lib/knowledge/application/operations.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ describe('knowledge operation registry', () => {
7171
'knowledge.connectors.create',
7272
'knowledge.connectors.update',
7373
'knowledge.connectors.access.update',
74+
'knowledge.search.personal-integrations.connect',
75+
'knowledge.search.personal-integrations.list',
7476
'knowledge.search.sources.list',
7577
'knowledge.search.sources.overview',
7678
'knowledge.search.sources.progress',

apps/sim/lib/slack-search/assistant-stream.test.ts

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ vi.mock('@/executor/utils/resolved-secret-content-projection', () => ({
2121
projectResolvedSecretDiagnosticContent: api.project,
2222
}))
2323

24+
import type {
25+
ToolCallStreamEvent,
26+
ToolResultStreamEvent,
27+
} from '@/lib/copilot/request/session/contract'
2428
import type { OrchestratorResult } from '@/lib/copilot/request/types'
2529
import { publicSlackAnswer, SlackSearchAssistantStream } from '@/lib/slack-search/assistant-stream'
2630
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
@@ -80,6 +84,149 @@ function setup(deliverConnections = vi.fn().mockResolvedValue(undefined)) {
8084
}),
8185
}
8286
}
87+
88+
function toolCall(toolName = 'list_integrations', toolCallId = 'tool-1'): ToolCallStreamEvent {
89+
return {
90+
type: 'tool',
91+
payload: {
92+
phase: 'call',
93+
toolName,
94+
toolCallId,
95+
executor: 'sim',
96+
mode: 'sync',
97+
status: 'executing',
98+
},
99+
}
100+
}
101+
102+
function toolResult(
103+
toolName = 'list_integrations',
104+
toolCallId = 'tool-1',
105+
success = true
106+
): ToolResultStreamEvent {
107+
return {
108+
type: 'tool',
109+
payload: { phase: 'result', toolName, toolCallId, executor: 'sim', mode: 'sync', success },
110+
}
111+
}
112+
113+
describe('Slack tool progress', () => {
114+
it.each([
115+
['list_integrations', 'Listing connected integrations…'],
116+
['search_workspace', 'Searching documents…'],
117+
['read_document', 'Reading documents…'],
118+
])('shows %s as a task and completes that same task once', async (name, title) => {
119+
const { stream } = setup()
120+
await stream.start()
121+
await stream.onEvent(toolCall(name))
122+
await stream.onEvent(toolCall(name))
123+
await stream.onEvent(toolResult(name))
124+
await stream.onEvent(toolResult(name))
125+
await stream.finish(result)
126+
const chunks = api.append.mock.calls.flatMap((call) => call[3])
127+
expect(chunks).toEqual([
128+
{ type: 'task_update', id: expect.any(String), title, status: 'in_progress' },
129+
{ type: 'task_update', id: chunks[0].id, title, status: 'complete' },
130+
])
131+
expect(api.stop.mock.calls[0][6]).toEqual([])
132+
})
133+
134+
it('keeps parallel calls separate when their results arrive out of order', async () => {
135+
const { stream } = setup()
136+
await stream.start()
137+
await stream.onEvent(toolCall('search_workspace', 'search-1'))
138+
await stream.onEvent(toolCall('search_workspace', 'search-2'))
139+
await stream.onEvent(toolResult('search_workspace', 'search-2'))
140+
await stream.onEvent(toolResult('search_workspace', 'search-1'))
141+
const chunks = api.append.mock.calls.flatMap((call) => call[3])
142+
expect(chunks[0].id).not.toBe(chunks[1].id)
143+
expect(chunks[2]).toEqual({ ...chunks[1], status: 'complete' })
144+
expect(chunks[3]).toEqual({ ...chunks[0], status: 'complete' })
145+
})
146+
147+
it('withholds partial, hidden, internal, subagent, and unsupported tools', async () => {
148+
const { stream } = setup()
149+
await stream.start()
150+
for (const attributes of [
151+
{ partial: true },
152+
{ status: 'generating' as const },
153+
{ ui: { hidden: true } },
154+
{ ui: { internal: true } },
155+
]) {
156+
const event = toolCall()
157+
await stream.onEvent({ ...event, payload: { ...event.payload, ...attributes } })
158+
}
159+
await stream.onEvent({ ...toolCall(), scope: { lane: 'subagent', agentId: 'private-agent' } })
160+
await stream.onEvent(toolCall('internal_tool'))
161+
await stream.onEvent(toolResult())
162+
expect(api.append).not.toHaveBeenCalled()
163+
await stream.onEvent(toolCall())
164+
expect(api.append).toHaveBeenCalledOnce()
165+
})
166+
167+
it('reports failed tools without exposing arguments, account labels, or backend errors', async () => {
168+
const { stream } = setup()
169+
await stream.start()
170+
const call = toolCall()
171+
await stream.onEvent({
172+
...call,
173+
payload: { ...call.payload, arguments: { query: 'private argument' } },
174+
})
175+
const failed = toolResult('list_integrations', 'tool-1', false)
176+
await stream.onEvent({
177+
...failed,
178+
payload: {
179+
...failed.payload,
180+
error: 'private error',
181+
output: { accountLabel: 'private account' },
182+
},
183+
})
184+
const chunks = api.append.mock.calls.flatMap((call) => call[3])
185+
expect(chunks[1]).toEqual({ ...chunks[0], status: 'error' })
186+
expect(JSON.stringify(chunks)).not.toContain('private')
187+
})
188+
189+
it('marks unfinished tasks failed when the Assistant fails', async () => {
190+
const { stream } = setup()
191+
await stream.start()
192+
await stream.onEvent(toolCall())
193+
await stream.finishWithError()
194+
expect(api.stop.mock.calls[0][6]).toEqual([
195+
{ ...api.append.mock.calls[0][3][0], status: 'error' },
196+
])
197+
})
198+
199+
it('aborts an ambiguous progress send and cleans up once without replaying it', async () => {
200+
const { stream, controller } = setup()
201+
await stream.start()
202+
api.append.mockRejectedValueOnce(new Error('progress response lost'))
203+
await expect(stream.onEvent(toolCall())).rejects.toThrow('progress response lost')
204+
expect(controller.signal.aborted).toBe(true)
205+
await expect(stream.onEvent(toolCall())).rejects.toThrow('progress response lost')
206+
await stream.terminateAfterFailure()
207+
await stream.terminateAfterFailure()
208+
expect(api.append).toHaveBeenCalledOnce()
209+
expect(api.stop).toHaveBeenCalledOnce()
210+
expect(api.stop.mock.calls[0][6]).toEqual([
211+
{ ...api.append.mock.calls[0][3][0], status: 'error' },
212+
])
213+
})
214+
215+
it('does not send task updates after cancellation or revoked delivery authority', async () => {
216+
const { stream, controller, beforeDelivery } = setup()
217+
await stream.start()
218+
beforeDelivery.mockRejectedValueOnce(new Error('authority revoked'))
219+
await expect(stream.onEvent(toolCall())).rejects.toThrow('authority revoked')
220+
expect(controller.signal.aborted).toBe(true)
221+
expect(api.append).not.toHaveBeenCalled()
222+
const cancelled = setup()
223+
await cancelled.stream.start()
224+
cancelled.controller.abort(new Error('stopped'))
225+
await expect(cancelled.stream.onEvent(toolCall())).rejects.toThrow('stopped')
226+
expect(api.append).not.toHaveBeenCalled()
227+
})
228+
})
229+
83230
describe('Slack Assistant delivery', () => {
84231
it('withholds split connection tags, delivers validated controls, and leaves a visible next step', async () => {
85232
const deliver = vi.fn().mockResolvedValue(undefined)
@@ -249,7 +396,8 @@ describe('Slack Assistant delivery', () => {
249396
type: 'section',
250397
text: { type: 'plain_text', text: 'I couldn’t complete this search. Please try again.' },
251398
},
252-
]
399+
],
400+
[]
253401
)
254402
expect(api.append).not.toHaveBeenCalled()
255403
})

apps/sim/lib/slack-search/assistant-stream.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { toError } from '@sim/utils/errors'
2+
import { generateId } from '@sim/utils/id'
23
import { truncate } from '@sim/utils/string'
34
import {
45
collectRetrievalCitationEvidence,
56
parseCitationRecord,
67
type RetrievalCitationBlock,
78
} from '@/lib/copilot/chat/citation-evidence'
89
import { redactSensitiveContent } from '@/lib/copilot/chat/sim-key-redaction'
9-
import type { StreamEvent } from '@/lib/copilot/request/session/contract'
10+
import type {
11+
StreamEvent,
12+
ToolCallStreamEvent,
13+
ToolResultStreamEvent,
14+
} from '@/lib/copilot/request/session/contract'
1015
import type { OrchestratorResult } from '@/lib/copilot/request/types'
1116
import {
1217
parseSearchConnectionTargets,
@@ -15,6 +20,7 @@ import {
1520
import { SLACK_SEARCH_FAILED_ANSWER } from '@/lib/slack-search/constants'
1621
import {
1722
appendSlackAgentStream,
23+
type SlackStreamChunk,
1824
setSlackAgentSessionStatus,
1925
startSlackAgentStream,
2026
stopSlackAgentStream,
@@ -100,6 +106,14 @@ const FAILURE_BLOCKS: Record<string, unknown>[] = [
100106
{ type: 'section', text: { type: 'plain_text', text: SLACK_SEARCH_FAILED_ANSWER } },
101107
]
102108

109+
const TOOL_PROGRESS_TITLES = new Map([
110+
['list_integrations', 'Listing connected integrations…'],
111+
['search_workspace', 'Searching documents…'],
112+
['read_document', 'Reading documents…'],
113+
])
114+
115+
type ToolProgress = Extract<SlackStreamChunk, { type: 'task_update' }>
116+
103117
/** Serial delivery through the same provider primitives as Slack blocks; ambiguous sends are terminal. */
104118
export class SlackSearchAssistantStream {
105119
private stream?: { channel: string; ts: string }
@@ -111,6 +125,7 @@ export class SlackSearchAssistantStream {
111125
private closeAttempted = false
112126
private separateNextText = false
113127
private evidence = new Map<string, Record<string, unknown>>()
128+
private toolProgress = new Map<string, { toolName: string; chunk: ToolProgress }>()
114129
constructor(private readonly options: AssistantStreamOptions) {}
115130

116131
private async deliver(action: () => Promise<void>) {
@@ -159,7 +174,15 @@ export class SlackSearchAssistantStream {
159174
},
160175
])
161176
}
162-
if (event.type === 'tool' && !event.scope) this.separateNextText = true
177+
if (event.type === 'tool' && !event.scope) {
178+
this.separateNextText = true
179+
if (
180+
'phase' in event.payload &&
181+
(event.payload.phase === 'call' || event.payload.phase === 'result')
182+
) {
183+
await this.updateToolProgress(event.payload)
184+
}
185+
}
163186
if (event.type !== 'text' || event.payload.channel !== 'assistant' || event.scope) return
164187
if (this.separateNextText && this.text) this.text += '\n\n'
165188
this.separateNextText = false
@@ -168,6 +191,57 @@ export class SlackSearchAssistantStream {
168191
if (Date.now() - this.lastSentAt >= 750) await this.flush(false)
169192
}
170193

194+
/** Only static labels reach Slack; arguments, account details, and backend errors stay private. */
195+
private async updateToolProgress(
196+
payload: ToolCallStreamEvent['payload'] | ToolResultStreamEvent['payload']
197+
) {
198+
const title = TOOL_PROGRESS_TITLES.get(payload.toolName)
199+
if (!title) return
200+
const existing = this.toolProgress.get(payload.toolCallId)
201+
let chunk: ToolProgress
202+
if (payload.phase === 'call') {
203+
if (
204+
existing ||
205+
payload.partial ||
206+
payload.ui?.hidden ||
207+
payload.ui?.internal ||
208+
(payload.status !== undefined && payload.status !== 'executing')
209+
)
210+
return
211+
chunk = { type: 'task_update', id: generateId(), title, status: 'in_progress' }
212+
this.toolProgress.set(payload.toolCallId, { toolName: payload.toolName, chunk })
213+
} else {
214+
if (!existing || existing.chunk.status !== 'in_progress') return
215+
if (existing.toolName !== payload.toolName)
216+
throw new Error('Slack tool progress identity changed')
217+
chunk = {
218+
...existing.chunk,
219+
status:
220+
payload.success && (!payload.status || payload.status === 'success')
221+
? 'complete'
222+
: 'error',
223+
}
224+
}
225+
await this.deliver(async () => {
226+
if (!this.stream || this.closed) throw new Error('Slack stream is not active')
227+
await appendSlackAgentStream(
228+
this.options.token,
229+
this.stream.channel,
230+
this.stream.ts,
231+
[chunk],
232+
this.options.controller.signal
233+
)
234+
})
235+
this.toolProgress.set(payload.toolCallId, { toolName: payload.toolName, chunk })
236+
}
237+
238+
/** Finalize interrupted tasks in the single stop request, including ambiguous progress sends. */
239+
private interruptedToolProgress(): ToolProgress[] {
240+
return [...this.toolProgress.values()]
241+
.filter(({ chunk }) => chunk.status === 'in_progress')
242+
.map(({ chunk }) => ({ ...chunk, status: 'error' }))
243+
}
244+
171245
private collectSources(blocks: readonly RetrievalCitationBlock[]) {
172246
for (const [id, source] of collectRetrievalCitationEvidence(blocks)) {
173247
if (!this.evidence.has(id)) this.evidence.set(id, source)
@@ -263,7 +337,8 @@ export class SlackSearchAssistantStream {
263337
this.stream.ts,
264338
'active',
265339
signal,
266-
FAILURE_BLOCKS
340+
FAILURE_BLOCKS,
341+
this.interruptedToolProgress()
267342
)
268343
this.closed = true
269344
}
@@ -278,7 +353,8 @@ export class SlackSearchAssistantStream {
278353
this.stream.ts,
279354
'active',
280355
this.options.controller.signal,
281-
blocks
356+
blocks,
357+
this.interruptedToolProgress()
282358
)
283359
this.closed = true
284360
})

apps/sim/lib/webhooks/slack-agent-api.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,28 @@ describe('Slack agent API transport', () => {
8585
})
8686
})
8787

88+
it('finishes task updates in the same stop request as the final blocks', async () => {
89+
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true })))
90+
vi.stubGlobal('fetch', fetchMock)
91+
const chunks = [
92+
{
93+
type: 'task_update' as const,
94+
id: 'task-1',
95+
title: 'Searching documents…',
96+
status: 'error' as const,
97+
},
98+
]
99+
const blocks = [{ type: 'section', text: { type: 'plain_text', text: 'Please try again.' } }]
100+
await stopSlackAgentStream('xoxb-test', 'D1', '101.2', 'active', undefined, blocks, chunks)
101+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
102+
channel: 'D1',
103+
ts: '101.2',
104+
session_status: 'active',
105+
blocks,
106+
chunks,
107+
})
108+
})
109+
88110
it('sets the human initiator when creating a processing session', async () => {
89111
const fetchMock = vi
90112
.fn()

apps/sim/lib/webhooks/slack-agent-api.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,19 @@ export async function stopSlackAgentStream(
148148
ts: string,
149149
sessionStatus: 'active' | 'processing' | 'suspended',
150150
signal?: AbortSignal,
151-
blocks?: Record<string, unknown>[]
151+
blocks?: Record<string, unknown>[],
152+
chunks?: SlackStreamChunk[]
152153
): Promise<void> {
153154
await callSlackAgentApi(
154155
'chat.stopStream',
155156
token,
156-
{ channel, ts, session_status: sessionStatus, ...(blocks?.length ? { blocks } : {}) },
157+
{
158+
channel,
159+
ts,
160+
session_status: sessionStatus,
161+
...(blocks?.length ? { blocks } : {}),
162+
...(chunks?.length ? { chunks } : {}),
163+
},
157164
signal
158165
)
159166
}

0 commit comments

Comments
 (0)