Skip to content

Commit 1cc4fb1

Browse files
fix(slack): preserve text order around tool progress (#7716)
* fix(slack): preserve text order around tool progress * fix(slack): defer task progress behind withheld text
1 parent 1da2820 commit 1cc4fb1

2 files changed

Lines changed: 334 additions & 26 deletions

File tree

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

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ function setup(deliverConnections = vi.fn().mockResolvedValue(undefined)) {
6969
} as unknown as ResolvedSecretTraceRegistry
7070
return {
7171
controller,
72+
registry,
7273
beforeDelivery,
7374
beforeCleanup,
7475
stream: new SlackSearchAssistantStream({
@@ -111,6 +112,279 @@ function toolResult(
111112
}
112113

113114
describe('Slack tool progress', () => {
115+
it('preserves task positions when secret projection defers delivery until completion', async () => {
116+
const { stream, registry } = setup()
117+
vi.spyOn(registry, 'getActiveMatches').mockReturnValue([
118+
{ plaintext: 'private-token', replacement: '[REDACTED_SECRET]' },
119+
])
120+
api.project.mockImplementation((value: unknown) => ({
121+
safe: true,
122+
value:
123+
typeof value === 'string' ? value.replaceAll('private-token', '[REDACTED_SECRET]') : value,
124+
}))
125+
await stream.start()
126+
await stream.onEvent({
127+
type: 'text',
128+
payload: { channel: 'assistant', text: 'Checking private-token.' },
129+
})
130+
await stream.onEvent(toolCall('search_workspace'))
131+
await stream.onEvent(toolResult('search_workspace'))
132+
await stream.onEvent({
133+
type: 'text',
134+
payload: { channel: 'assistant', text: 'Found a result.' },
135+
})
136+
expect(api.append).not.toHaveBeenCalled()
137+
await stream.finish(result)
138+
const chunks = api.append.mock.calls.flatMap((call) => call[3])
139+
expect(chunks).toEqual([
140+
{ type: 'markdown_text', text: 'Checking [REDACTED_SECRET].\n\n' },
141+
{
142+
type: 'task_update',
143+
id: expect.any(String),
144+
title: 'Searching documents…',
145+
status: 'in_progress',
146+
},
147+
{ type: 'task_update', id: chunks[1].id, title: 'Searching documents…', status: 'complete' },
148+
{ type: 'markdown_text', text: 'Found a result.' },
149+
])
150+
expect(JSON.stringify(api.append.mock.calls)).not.toContain('private-token')
151+
})
152+
153+
it('withholds tasks and following text until preceding citation evidence arrives', async () => {
154+
const { stream } = setup()
155+
await stream.start()
156+
await stream.onEvent({
157+
type: 'text',
158+
payload: {
159+
channel: 'assistant',
160+
text: 'Checking <source>{"id":"late"}</source> for details.',
161+
},
162+
})
163+
await stream.onEvent(toolCall('search_workspace'))
164+
await stream.onEvent({
165+
type: 'text',
166+
payload: { channel: 'assistant', text: 'Found a result. ' },
167+
})
168+
expect(api.append.mock.calls.flatMap((call) => call[3])).toEqual([
169+
{ type: 'markdown_text', text: 'Checking ' },
170+
])
171+
const completed = toolResult('search_workspace')
172+
await stream.onEvent({
173+
...completed,
174+
payload: {
175+
...completed.payload,
176+
output: {
177+
data: {
178+
results: [
179+
{
180+
citationId: 'late',
181+
citationUrl: 'https://example.com/policy',
182+
documentName: 'Policy',
183+
},
184+
],
185+
},
186+
},
187+
},
188+
})
189+
const chunks = api.append.mock.calls.flatMap((call) => call[3])
190+
expect(chunks).toEqual([
191+
{ type: 'markdown_text', text: 'Checking ' },
192+
{ type: 'markdown_text', text: '[Policy](<https://example.com/policy>) for details.\n\n' },
193+
{
194+
type: 'task_update',
195+
id: expect.any(String),
196+
title: 'Searching documents…',
197+
status: 'in_progress',
198+
},
199+
{ type: 'task_update', id: chunks[2].id, title: 'Searching documents…', status: 'complete' },
200+
{ type: 'markdown_text', text: 'Found a result. ' },
201+
])
202+
await stream.finish(result)
203+
expect(api.append.mock.calls.flatMap((call) => call[3])).toEqual(chunks)
204+
})
205+
206+
it('rejects a tool boundary whose prefix is unsafe in the complete secret projection', async () => {
207+
const { stream, registry } = setup()
208+
const secret = 'private-\n\ntoken'
209+
vi.spyOn(registry, 'getActiveMatches').mockReturnValue([
210+
{ plaintext: secret, replacement: '[REDACTED_SECRET]' },
211+
])
212+
api.project.mockImplementation((value: unknown) => ({
213+
safe: true,
214+
value: typeof value === 'string' ? value.replaceAll(secret, '[REDACTED_SECRET]') : value,
215+
}))
216+
await stream.start()
217+
await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'private-' } })
218+
await stream.onEvent(toolCall('search_workspace'))
219+
await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'token' } })
220+
await expect(stream.finish(result)).rejects.toThrow(
221+
'The safe answer changed at a tool boundary'
222+
)
223+
expect(api.append).not.toHaveBeenCalled()
224+
})
225+
226+
it('never retries a deferred task after its append fails ambiguously', async () => {
227+
const { stream, registry, controller } = setup()
228+
vi.spyOn(registry, 'getActiveMatches').mockReturnValue([
229+
{ plaintext: 'private-token', replacement: '[REDACTED_SECRET]' },
230+
])
231+
await stream.start()
232+
await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'Checking.' } })
233+
await stream.onEvent(toolCall('search_workspace'))
234+
expect(api.append).not.toHaveBeenCalled()
235+
api.append.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('response lost'))
236+
await expect(stream.finish(result)).rejects.toThrow('response lost')
237+
expect(controller.signal.aborted).toBe(true)
238+
await stream.terminateAfterFailure()
239+
await stream.terminateAfterFailure()
240+
expect(api.append).toHaveBeenCalledTimes(2)
241+
expect(api.stop).toHaveBeenCalledOnce()
242+
expect(api.stop.mock.calls[0][6]).toEqual([
243+
{ ...api.append.mock.calls[1][3][0], status: 'error' },
244+
])
245+
})
246+
247+
it('omits unverified citations at completion without moving tasks ahead of their text', async () => {
248+
const { stream } = setup()
249+
await stream.start()
250+
await stream.onEvent({
251+
type: 'text',
252+
payload: {
253+
channel: 'assistant',
254+
text: 'Checking <source>{"id":"missing"}</source> for details.',
255+
},
256+
})
257+
await stream.onEvent(toolCall('search_workspace'))
258+
await stream.onEvent(toolResult('search_workspace'))
259+
await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'Done.' } })
260+
await stream.finish(result)
261+
const chunks = api.append.mock.calls.flatMap((call) => call[3])
262+
expect(chunks.map((chunk) => chunk.type)).toEqual([
263+
'markdown_text',
264+
'markdown_text',
265+
'task_update',
266+
'task_update',
267+
'markdown_text',
268+
])
269+
expect(chunks[1].text).toBe(' for details.\n\n')
270+
expect(chunks[4].text).toBe('Done.')
271+
expect(deliveredText()).not.toContain('missing')
272+
})
273+
274+
it('does not introduce a withheld task when delivery is cancelled', async () => {
275+
const { stream, controller } = setup()
276+
await stream.start()
277+
await stream.onEvent({
278+
type: 'text',
279+
payload: {
280+
channel: 'assistant',
281+
text: 'Checking <source>{"id":"missing"}</source> for details.',
282+
},
283+
})
284+
await stream.onEvent(toolCall('search_workspace'))
285+
controller.abort(new Error('stopped'))
286+
await stream.terminateAfterFailure()
287+
expect(api.stop.mock.calls[0][6]).toEqual([])
288+
expect(api.append.mock.calls.flatMap((call) => call[3])).toEqual([
289+
{ type: 'markdown_text', text: 'Checking ' },
290+
])
291+
})
292+
293+
it('flushes a batched sentence before starting tool progress', async () => {
294+
vi.spyOn(Date, 'now').mockReturnValue(1000)
295+
try {
296+
const { stream } = setup()
297+
await stream.start()
298+
await stream.onEvent({
299+
type: 'text',
300+
payload: { channel: 'assistant', text: "I'll search " },
301+
})
302+
await stream.onEvent({
303+
type: 'text',
304+
payload: { channel: 'assistant', text: 'the connected sources for the handbook.' },
305+
})
306+
await stream.onEvent(toolCall('search_workspace'))
307+
const chunks = api.append.mock.calls.flatMap((call) => call[3])
308+
expect(chunks).toEqual([
309+
{ type: 'markdown_text', text: "I'll search " },
310+
{
311+
type: 'markdown_text',
312+
text: 'the connected sources for the handbook.\n\n',
313+
},
314+
{
315+
type: 'task_update',
316+
id: expect.any(String),
317+
title: 'Searching documents…',
318+
status: 'in_progress',
319+
},
320+
])
321+
} finally {
322+
vi.restoreAllMocks()
323+
}
324+
})
325+
326+
it('keeps text contiguous across preparatory and hidden tool events', async () => {
327+
const { stream } = setup()
328+
await stream.start()
329+
await stream.onEvent({
330+
type: 'text',
331+
payload: { channel: 'assistant', text: "I'll search" },
332+
})
333+
for (const attributes of [
334+
{ partial: true },
335+
{ status: 'generating' as const },
336+
{ ui: { hidden: true } },
337+
{ ui: { internal: true } },
338+
]) {
339+
const event = toolCall('search_workspace')
340+
await stream.onEvent({ ...event, payload: { ...event.payload, ...attributes } })
341+
}
342+
await stream.onEvent({
343+
type: 'text',
344+
payload: { channel: 'assistant', text: ' the connected sources.' },
345+
})
346+
await stream.finish(result)
347+
expect(deliveredText()).toBe("I'll search the connected sources.")
348+
expect(
349+
api.append.mock.calls
350+
.flatMap((call) => call[3])
351+
.every((chunk) => chunk.type === 'markdown_text')
352+
).toBe(true)
353+
})
354+
355+
it('serializes concurrent text and tool events without duplicating buffered text', async () => {
356+
const { stream } = setup()
357+
await stream.start()
358+
let releaseAppend!: () => void
359+
api.append.mockImplementationOnce(
360+
() =>
361+
new Promise<void>((resolve) => {
362+
releaseAppend = resolve
363+
})
364+
)
365+
const text = stream.onEvent({
366+
type: 'text',
367+
payload: { channel: 'assistant', text: "I'll search the connected sources. " },
368+
})
369+
await vi.waitFor(() => expect(api.append).toHaveBeenCalledOnce(), { interval: 1 })
370+
const call = stream.onEvent(toolCall('search_workspace'))
371+
const completed = stream.onEvent(toolResult('search_workspace'))
372+
const finished = stream.finish(result)
373+
expect(api.append).toHaveBeenCalledOnce()
374+
expect(api.stop).not.toHaveBeenCalled()
375+
releaseAppend()
376+
await Promise.all([text, call, completed, finished])
377+
const chunks = api.append.mock.calls.flatMap((call) => call[3])
378+
expect(deliveredText()).toBe("I'll search the connected sources. \n\n")
379+
expect(chunks.map((chunk) => chunk.type)).toEqual([
380+
'markdown_text',
381+
'markdown_text',
382+
'task_update',
383+
'task_update',
384+
])
385+
expect(chunks[3]).toEqual({ ...chunks[2], status: 'complete' })
386+
})
387+
114388
it.each([
115389
['list_integrations', 'Listing connected integrations…'],
116390
['search_workspace', 'Searching documents…'],

0 commit comments

Comments
 (0)