11/** @vitest -environment node */
22import { 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+ } ) )
511vi . 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} ) )
1420vi . mock ( '@/executor/utils/resolved-secret-content-projection' , ( ) => ( {
15- projectResolvedSecretDiagnosticContent : ( value : unknown ) => ( { safe : true , value } ) ,
21+ projectResolvedSecretDiagnosticContent : api . project ,
1622} ) )
1723
1824import type { OrchestratorResult } from '@/lib/copilot/request/types'
@@ -23,7 +29,32 @@ const result: OrchestratorResult = { success: true, content: '', contentBlocks:
2329beforeEach ( ( ) => {
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+ }
2758function 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\\] & <@everyone> ' )
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