Skip to content

Commit 950cda5

Browse files
fix(slack): defer task progress behind withheld text
1 parent 4a4ca0b commit 950cda5

2 files changed

Lines changed: 229 additions & 22 deletions

File tree

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

Lines changed: 179 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,184 @@ 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+
114293
it('flushes a batched sentence before starting tool progress', async () => {
115294
vi.spyOn(Date, 'now').mockReturnValue(1000)
116295
try {

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

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ export class SlackSearchAssistantStream {
126126
private pendingEvents: Promise<void> = Promise.resolve()
127127
private evidence = new Map<string, Record<string, unknown>>()
128128
private toolProgress = new Map<string, { toolName: string; chunk: ToolProgress }>()
129+
private pendingProgress: { textEnd: number; chunk: ToolProgress }[] = []
130+
private deliveredProgress = new Map<string, ToolProgress>()
129131
constructor(private readonly options: AssistantStreamOptions) {}
130132

131133
private async deliver(action: () => Promise<void>) {
@@ -184,17 +186,18 @@ export class SlackSearchAssistantStream {
184186
'phase' in event.payload &&
185187
(event.payload.phase === 'call' || event.payload.phase === 'result')
186188
) {
187-
await this.updateToolProgress(event.payload)
189+
this.queueToolProgress(event.payload)
188190
}
189191
}
192+
if (event.type === 'tool' && this.pendingProgress.length) await this.flush(false)
190193
if (event.type !== 'text' || event.payload.channel !== 'assistant' || event.scope) return
191194
this.text += event.payload.text
192195
if (this.text.length > 128_000) throw new Error('Slack answer exceeds the supported size')
193196
if (Date.now() - this.lastSentAt >= 750) await this.flush(false)
194197
}
195198

196199
/** Only static labels reach Slack; arguments, account details, and backend errors stay private. */
197-
private async updateToolProgress(
200+
private queueToolProgress(
198201
payload: ToolCallStreamEvent['payload'] | ToolResultStreamEvent['payload']
199202
) {
200203
const title = TOOL_PROGRESS_TITLES.get(payload.toolName)
@@ -212,9 +215,7 @@ export class SlackSearchAssistantStream {
212215
return
213216
/** Close the preceding text segment so batching cannot place its tail after the task. */
214217
if (this.text && !this.text.endsWith('\n\n')) this.text += '\n\n'
215-
await this.flush(false)
216218
chunk = { type: 'task_update', id: generateId(), title, status: 'in_progress' }
217-
this.toolProgress.set(payload.toolCallId, { toolName: payload.toolName, chunk })
218219
} else {
219220
if (!existing || existing.chunk.status !== 'in_progress') return
220221
if (existing.toolName !== payload.toolName)
@@ -227,24 +228,16 @@ export class SlackSearchAssistantStream {
227228
: 'error',
228229
}
229230
}
230-
await this.deliver(async () => {
231-
if (!this.stream || this.closed) throw new Error('Slack stream is not active')
232-
await appendSlackAgentStream(
233-
this.options.token,
234-
this.stream.channel,
235-
this.stream.ts,
236-
[chunk],
237-
this.options.controller.signal
238-
)
239-
})
240231
this.toolProgress.set(payload.toolCallId, { toolName: payload.toolName, chunk })
232+
/** Updating an existing task does not introduce a new position in Slack's timeline. */
233+
this.pendingProgress.push({ textEnd: payload.phase === 'call' ? this.text.length : 0, chunk })
241234
}
242235

243236
/** Finalize interrupted tasks in the single stop request, including ambiguous progress sends. */
244237
private interruptedToolProgress(): ToolProgress[] {
245-
return [...this.toolProgress.values()]
246-
.filter(({ chunk }) => chunk.status === 'in_progress')
247-
.map(({ chunk }) => ({ ...chunk, status: 'error' }))
238+
return [...this.deliveredProgress.values()]
239+
.filter((chunk) => chunk.status === 'in_progress')
240+
.map((chunk) => ({ ...chunk, status: 'error' }))
248241
}
249242

250243
private collectSources(blocks: readonly RetrievalCitationBlock[]) {
@@ -254,13 +247,10 @@ export class SlackSearchAssistantStream {
254247
}
255248

256249
private async flush(complete: boolean) {
257-
const { registry, token, controller } = this.options
250+
const { registry } = this.options
258251
if (!registry.isComplete()) throw new Error('Answer secret provenance is unavailable')
259252
/** Active secret literals can straddle deltas; project their complete answer instead. */
260253
if (!complete && registry.getActiveMatches().length) return
261-
const projection = projectResolvedSecretDiagnosticContent(this.text, registry, 512_000)
262-
if (!projection.safe || typeof projection.value !== 'string')
263-
throw new Error('Answer could not be safely projected')
264254
const sources = new Map<string, string>()
265255
for (const [id, source] of this.evidence) {
266256
const projected = projectResolvedSecretDiagnosticContent(source, registry)
@@ -271,8 +261,46 @@ export class SlackSearchAssistantStream {
271261
: ''
272262
)
273263
}
274-
const text = publicSlackAnswer(redactSensitiveContent(projection.value), complete, sources)
264+
const text = this.projectAnswer(this.text, complete, sources)
265+
if (!text.startsWith(this.sent)) throw new Error('The safe answer changed after delivery')
266+
while (this.pendingProgress.length) {
267+
const { textEnd, chunk } = this.pendingProgress[0]!
268+
const preceding = this.text.slice(0, textEnd)
269+
const prefix = this.projectAnswer(preceding, complete, sources)
270+
/** A prefix must remain safe when projected as part of the complete answer. */
271+
if (!text.startsWith(prefix)) throw new Error('The safe answer changed at a tool boundary')
272+
await this.appendText(prefix)
273+
/** Unresolved citations and partial markup must not let a task overtake withheld text. */
274+
if (!complete && prefix !== this.projectAnswer(preceding, true, sources)) return
275+
await this.deliver(async () => {
276+
if (!this.stream || this.closed) throw new Error('Slack stream is not active')
277+
/** Include an ambiguously started task in failure cleanup, but never an unsent task. */
278+
if (!this.deliveredProgress.has(chunk.id)) this.deliveredProgress.set(chunk.id, chunk)
279+
await appendSlackAgentStream(
280+
this.options.token,
281+
this.stream.channel,
282+
this.stream.ts,
283+
[chunk],
284+
this.options.controller.signal
285+
)
286+
})
287+
this.deliveredProgress.set(chunk.id, chunk)
288+
this.pendingProgress.shift()
289+
}
290+
await this.appendText(text)
291+
}
292+
293+
private projectAnswer(text: string, complete: boolean, sources: ReadonlyMap<string, string>) {
294+
const projection = projectResolvedSecretDiagnosticContent(text, this.options.registry, 512_000)
295+
if (!projection.safe || typeof projection.value !== 'string')
296+
throw new Error('Answer could not be safely projected')
297+
return publicSlackAnswer(redactSensitiveContent(projection.value), complete, sources)
298+
}
299+
300+
private async appendText(text: string) {
301+
if (this.sent.startsWith(text)) return
275302
if (!text.startsWith(this.sent)) throw new Error('The safe answer changed after delivery')
303+
const { token, controller } = this.options
276304
let pending = text.slice(this.sent.length)
277305
while (pending.length) {
278306
let end = Math.min(4000, pending.length)

0 commit comments

Comments
 (0)