@@ -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
113114describe ( '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