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