Skip to content

Commit bec75cf

Browse files
fix(slack): render source citations inline with answers
1 parent 3856735 commit bec75cf

2 files changed

Lines changed: 336 additions & 37 deletions

File tree

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

Lines changed: 246 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
/** @vitest-environment node */
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
33

4-
const api = vi.hoisted(() => ({ start: vi.fn(), append: vi.fn(), stop: vi.fn(), status: vi.fn() }))
4+
const api = vi.hoisted(() => ({
5+
start: vi.fn(),
6+
append: vi.fn(),
7+
stop: vi.fn(),
8+
status: vi.fn(),
9+
project: vi.fn(),
10+
}))
511
vi.mock('@/lib/webhooks/slack-agent-api', () => ({
612
startSlackAgentStream: api.start,
713
appendSlackAgentStream: api.append,
@@ -12,7 +18,7 @@ vi.mock('@/lib/copilot/chat/sim-key-redaction', () => ({
1218
redactSensitiveContent: (value: string) => value,
1319
}))
1420
vi.mock('@/executor/utils/resolved-secret-content-projection', () => ({
15-
projectResolvedSecretDiagnosticContent: (value: unknown) => ({ safe: true, value }),
21+
projectResolvedSecretDiagnosticContent: api.project,
1622
}))
1723

1824
import type { OrchestratorResult } from '@/lib/copilot/request/types'
@@ -23,7 +29,32 @@ const result: OrchestratorResult = { success: true, content: '', contentBlocks:
2329
beforeEach(() => {
2430
vi.clearAllMocks()
2531
api.start.mockResolvedValue({ channel: 'D1', ts: '1.2' })
32+
api.project.mockImplementation((value: unknown) => ({ safe: true, value }))
2633
})
34+
35+
function deliveredText() {
36+
return api.append.mock.calls
37+
.flatMap((call) => call[3])
38+
.map((chunk) => chunk.text)
39+
.join('')
40+
}
41+
42+
function retrieval(
43+
results: Record<string, unknown>[],
44+
name = 'search_workspace',
45+
success = true
46+
): OrchestratorResult['contentBlocks'][number] {
47+
return {
48+
type: 'tool_call',
49+
timestamp: 1,
50+
toolCall: {
51+
id: 'tool-1',
52+
name,
53+
status: success ? 'success' : 'error',
54+
result: { success, output: { data: { results } } },
55+
},
56+
}
57+
}
2758
function setup() {
2859
const controller = new AbortController()
2960
const beforeDelivery = vi.fn().mockResolvedValue(undefined)
@@ -192,9 +223,16 @@ describe('Slack Assistant delivery', () => {
192223
).rejects.toThrow('membership revoked')
193224
expect(api.append).not.toHaveBeenCalled()
194225
})
195-
it('adds source buttons only from successful retrieval evidence', async () => {
226+
it('places cited source names beside the supported text without a source footer', async () => {
196227
const { stream } = setup()
197228
await stream.start()
229+
await stream.onEvent({
230+
type: 'text',
231+
payload: {
232+
channel: 'assistant',
233+
text: 'Approval is required.<source>{"id":"real","url":"https://evil.example","title":"Forged"}</source> Then submit the request.',
234+
},
235+
})
198236
await stream.finish({
199237
...result,
200238
contentBlocks: [
@@ -215,6 +253,11 @@ describe('Slack Assistant delivery', () => {
215253
citationUrl: 'https://docs.example.com/real',
216254
documentName: 'Verified document',
217255
},
256+
{
257+
citationId: 'unused',
258+
citationUrl: 'https://docs.example.com/unused',
259+
documentName: 'Unused search result',
260+
},
218261
],
219262
},
220263
},
@@ -236,11 +279,208 @@ describe('Slack Assistant delivery', () => {
236279
},
237280
],
238281
})
239-
expect(api.stop.mock.calls[0][5]).toHaveLength(1)
240-
expect(api.stop.mock.calls[0][5][0].accessory.url).toBe('https://docs.example.com/real')
282+
expect(deliveredText()).toBe(
283+
'Approval is required. [Verified document](<https://docs.example.com/real>) Then submit the request.'
284+
)
285+
expect(api.stop.mock.calls[0][5]).toEqual([])
286+
})
287+
it('resolves tool-result citations during streaming before the final result', async () => {
288+
const { stream } = setup()
289+
await stream.start()
290+
await stream.onEvent({
291+
type: 'tool',
292+
payload: {
293+
phase: 'result',
294+
toolCallId: 'search-1',
295+
toolName: 'search_workspace',
296+
executor: 'sim',
297+
mode: 'sync',
298+
status: 'success',
299+
success: true,
300+
output: {
301+
data: {
302+
results: [
303+
{
304+
citationId: 'handbook',
305+
citationUrl: 'https://docs.example.com/handbook',
306+
documentName: 'Employee handbook',
307+
},
308+
],
309+
},
310+
},
311+
},
312+
})
313+
await stream.onEvent({
314+
type: 'text',
315+
payload: {
316+
channel: 'assistant',
317+
text: 'Ask your manager. <source>{"id":"handbook"}</source> ',
318+
},
319+
})
320+
expect(deliveredText()).toBe(
321+
'Ask your manager. [Employee handbook](<https://docs.example.com/handbook>) '
322+
)
323+
expect(api.stop).not.toHaveBeenCalled()
324+
await stream.finish(result)
325+
expect(api.stop.mock.calls[0][5]).toEqual([])
326+
})
327+
it('keeps each inline citation stable across every text boundary', () => {
328+
const source = '<source>{"id":"handbook"}</source>'
329+
const input = `Ask your manager.${source} Submit it here.${source} Done.`
330+
const link = '[Employee handbook](<https://docs.example.com/handbook>)'
331+
const sources = new Map([['handbook', link]])
332+
const expected = `Ask your manager. ${link} Submit it here. ${link} Done.`
333+
let previous = ''
334+
for (let end = 0; end <= input.length; end++) {
335+
const current = publicSlackAnswer(input.slice(0, end), false, sources)
336+
expect(current.startsWith(previous)).toBe(true)
337+
expect(expected.startsWith(current)).toBe(true)
338+
previous = current
339+
}
340+
expect(publicSlackAnswer(input, true, sources)).toBe(expected)
341+
})
342+
it('withholds text after an unresolved citation until evidence is available', () => {
343+
const input = 'Answer. <source>{"id":"late"}</source> More text. '
344+
expect(publicSlackAnswer(input, false)).toBe('Answer. ')
345+
expect(
346+
publicSlackAnswer(input, false, new Map([['late', '[Policy](<https://example.com/policy>)']]))
347+
).toBe('Answer. [Policy](<https://example.com/policy>) More text. ')
348+
expect(publicSlackAnswer(input, true)).toBe('Answer. More text. ')
349+
})
350+
it.each([
351+
['failed retrieval', 'search_workspace', false, 'https://example.com/document'],
352+
['unrelated tool', 'web_search', true, 'https://example.com/document'],
353+
['non-web URL', 'read_document', true, 'javascript:alert(1)'],
354+
['embedded credentials', 'read_document', true, 'https://user:password@example.com/document'],
355+
])('does not link %s', async (_name, tool, success, url) => {
356+
const { stream } = setup()
357+
await stream.start()
358+
await stream.onEvent({
359+
type: 'text',
360+
payload: {
361+
channel: 'assistant',
362+
text: 'Answer. <source>{"id":"invalid"}</source> End.',
363+
},
364+
})
365+
await stream.finish({
366+
...result,
367+
contentBlocks: [
368+
retrieval(
369+
[{ citationId: 'invalid', citationUrl: url, documentName: 'Unsafe source' }],
370+
tool,
371+
success
372+
),
373+
],
374+
})
375+
expect(deliveredText()).toBe('Answer. End.')
376+
expect(api.stop.mock.calls[0][5]).toEqual([])
377+
})
378+
it('omits source metadata that fails secret projection', async () => {
379+
const { stream } = setup()
380+
api.project.mockImplementation((value: unknown) =>
381+
typeof value === 'string' ? { safe: true, value } : { safe: false }
382+
)
383+
await stream.start()
384+
await stream.onEvent({
385+
type: 'text',
386+
payload: {
387+
channel: 'assistant',
388+
text: 'Answer. <source>{"id":"private"}</source> End.',
389+
},
390+
})
391+
await stream.finish({
392+
...result,
393+
contentBlocks: [
394+
retrieval([
395+
{
396+
citationId: 'private',
397+
citationUrl: 'https://example.com/private',
398+
documentName: 'Secret',
399+
},
400+
]),
401+
],
402+
})
403+
expect(deliveredText()).toBe('Answer. End.')
404+
})
405+
it('escapes source labels and bounds long titles without changing their destinations', async () => {
406+
const { stream } = setup()
407+
await stream.start()
408+
await stream.onEvent({
409+
type: 'text',
410+
payload: {
411+
channel: 'assistant',
412+
text: 'Answer. <source>{"id":"source"}</source>',
413+
},
414+
})
415+
await stream.finish({
416+
...result,
417+
contentBlocks: [
418+
retrieval([
419+
{
420+
citationId: 'source',
421+
citationUrl: 'https://example.com/a_(b)?a=1&b=2',
422+
documentName: `[Policy] & <@everyone>\n${'a'.repeat(100)}`,
423+
},
424+
]),
425+
],
426+
})
427+
expect(deliveredText()).toContain('[\\[Policy\\] &amp; &lt;@everyone&gt; ')
428+
expect(deliveredText()).toContain('](<https://example.com/a_(b)?a=1&b=2>)')
429+
expect(deliveredText()).not.toContain('a'.repeat(60))
430+
})
431+
it('keeps an inline link intact when it crosses the append size boundary', async () => {
432+
const { stream } = setup()
433+
const prefix = `${'a'.repeat(3970)} `
434+
await stream.start()
435+
await stream.onEvent({
436+
type: 'tool',
437+
payload: {
438+
phase: 'result',
439+
toolCallId: 'search-1',
440+
toolName: 'search_workspace',
441+
executor: 'sim',
442+
mode: 'sync',
443+
success: true,
444+
output: {
445+
data: {
446+
results: [
447+
{
448+
citationId: 'policy',
449+
citationUrl: 'https://example.com/policy',
450+
documentName: 'Employee policy',
451+
},
452+
],
453+
},
454+
},
455+
},
456+
})
457+
await stream.onEvent({
458+
type: 'text',
459+
payload: {
460+
channel: 'assistant',
461+
text: `${prefix}<source>{"id":"policy"}</source> Done.`,
462+
},
463+
})
464+
await stream.finish({
465+
...result,
466+
contentBlocks: [
467+
retrieval([
468+
{
469+
citationId: 'policy',
470+
citationUrl: 'https://example.com/policy',
471+
documentName: 'Employee policy',
472+
},
473+
]),
474+
],
475+
})
476+
const link = '[Employee policy](<https://example.com/policy>)'
477+
expect(deliveredText()).toBe(`${prefix}${link} Done.`)
478+
const chunks = api.append.mock.calls.flatMap((call) => call[3])
479+
expect(chunks.some((chunk) => chunk.text.includes(link))).toBe(true)
480+
expect(chunks.every((chunk) => chunk.text.length <= 4000)).toBe(true)
241481
})
242482
it.each([
243-
['Answer <source>{"id":"x","url":"https://evil.test"}</source> done ', 'Answer done '],
483+
['Answer <source>{"id":"x","url":"https://evil.test"}</source> done ', 'Answer '],
244484
[
245485
'Read [untrusted](https://evil.test) and https://evil.test/x now ',
246486
'Read untrusted and now ',

0 commit comments

Comments
 (0)