@@ -21,6 +21,10 @@ vi.mock('@/executor/utils/resolved-secret-content-projection', () => ({
2121 projectResolvedSecretDiagnosticContent : api . project ,
2222} ) )
2323
24+ import type {
25+ ToolCallStreamEvent ,
26+ ToolResultStreamEvent ,
27+ } from '@/lib/copilot/request/session/contract'
2428import type { OrchestratorResult } from '@/lib/copilot/request/types'
2529import { publicSlackAnswer , SlackSearchAssistantStream } from '@/lib/slack-search/assistant-stream'
2630import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
@@ -80,6 +84,149 @@ function setup(deliverConnections = vi.fn().mockResolvedValue(undefined)) {
8084 } ) ,
8185 }
8286}
87+
88+ function toolCall ( toolName = 'list_integrations' , toolCallId = 'tool-1' ) : ToolCallStreamEvent {
89+ return {
90+ type : 'tool' ,
91+ payload : {
92+ phase : 'call' ,
93+ toolName,
94+ toolCallId,
95+ executor : 'sim' ,
96+ mode : 'sync' ,
97+ status : 'executing' ,
98+ } ,
99+ }
100+ }
101+
102+ function toolResult (
103+ toolName = 'list_integrations' ,
104+ toolCallId = 'tool-1' ,
105+ success = true
106+ ) : ToolResultStreamEvent {
107+ return {
108+ type : 'tool' ,
109+ payload : { phase : 'result' , toolName, toolCallId, executor : 'sim' , mode : 'sync' , success } ,
110+ }
111+ }
112+
113+ describe ( 'Slack tool progress' , ( ) => {
114+ it . each ( [
115+ [ 'list_integrations' , 'Listing connected integrations…' ] ,
116+ [ 'search_workspace' , 'Searching documents…' ] ,
117+ [ 'read_document' , 'Reading documents…' ] ,
118+ ] ) ( 'shows %s as a task and completes that same task once' , async ( name , title ) => {
119+ const { stream } = setup ( )
120+ await stream . start ( )
121+ await stream . onEvent ( toolCall ( name ) )
122+ await stream . onEvent ( toolCall ( name ) )
123+ await stream . onEvent ( toolResult ( name ) )
124+ await stream . onEvent ( toolResult ( name ) )
125+ await stream . finish ( result )
126+ const chunks = api . append . mock . calls . flatMap ( ( call ) => call [ 3 ] )
127+ expect ( chunks ) . toEqual ( [
128+ { type : 'task_update' , id : expect . any ( String ) , title, status : 'in_progress' } ,
129+ { type : 'task_update' , id : chunks [ 0 ] . id , title, status : 'complete' } ,
130+ ] )
131+ expect ( api . stop . mock . calls [ 0 ] [ 6 ] ) . toEqual ( [ ] )
132+ } )
133+
134+ it ( 'keeps parallel calls separate when their results arrive out of order' , async ( ) => {
135+ const { stream } = setup ( )
136+ await stream . start ( )
137+ await stream . onEvent ( toolCall ( 'search_workspace' , 'search-1' ) )
138+ await stream . onEvent ( toolCall ( 'search_workspace' , 'search-2' ) )
139+ await stream . onEvent ( toolResult ( 'search_workspace' , 'search-2' ) )
140+ await stream . onEvent ( toolResult ( 'search_workspace' , 'search-1' ) )
141+ const chunks = api . append . mock . calls . flatMap ( ( call ) => call [ 3 ] )
142+ expect ( chunks [ 0 ] . id ) . not . toBe ( chunks [ 1 ] . id )
143+ expect ( chunks [ 2 ] ) . toEqual ( { ...chunks [ 1 ] , status : 'complete' } )
144+ expect ( chunks [ 3 ] ) . toEqual ( { ...chunks [ 0 ] , status : 'complete' } )
145+ } )
146+
147+ it ( 'withholds partial, hidden, internal, subagent, and unsupported tools' , async ( ) => {
148+ const { stream } = setup ( )
149+ await stream . start ( )
150+ for ( const attributes of [
151+ { partial : true } ,
152+ { status : 'generating' as const } ,
153+ { ui : { hidden : true } } ,
154+ { ui : { internal : true } } ,
155+ ] ) {
156+ const event = toolCall ( )
157+ await stream . onEvent ( { ...event , payload : { ...event . payload , ...attributes } } )
158+ }
159+ await stream . onEvent ( { ...toolCall ( ) , scope : { lane : 'subagent' , agentId : 'private-agent' } } )
160+ await stream . onEvent ( toolCall ( 'internal_tool' ) )
161+ await stream . onEvent ( toolResult ( ) )
162+ expect ( api . append ) . not . toHaveBeenCalled ( )
163+ await stream . onEvent ( toolCall ( ) )
164+ expect ( api . append ) . toHaveBeenCalledOnce ( )
165+ } )
166+
167+ it ( 'reports failed tools without exposing arguments, account labels, or backend errors' , async ( ) => {
168+ const { stream } = setup ( )
169+ await stream . start ( )
170+ const call = toolCall ( )
171+ await stream . onEvent ( {
172+ ...call ,
173+ payload : { ...call . payload , arguments : { query : 'private argument' } } ,
174+ } )
175+ const failed = toolResult ( 'list_integrations' , 'tool-1' , false )
176+ await stream . onEvent ( {
177+ ...failed ,
178+ payload : {
179+ ...failed . payload ,
180+ error : 'private error' ,
181+ output : { accountLabel : 'private account' } ,
182+ } ,
183+ } )
184+ const chunks = api . append . mock . calls . flatMap ( ( call ) => call [ 3 ] )
185+ expect ( chunks [ 1 ] ) . toEqual ( { ...chunks [ 0 ] , status : 'error' } )
186+ expect ( JSON . stringify ( chunks ) ) . not . toContain ( 'private' )
187+ } )
188+
189+ it ( 'marks unfinished tasks failed when the Assistant fails' , async ( ) => {
190+ const { stream } = setup ( )
191+ await stream . start ( )
192+ await stream . onEvent ( toolCall ( ) )
193+ await stream . finishWithError ( )
194+ expect ( api . stop . mock . calls [ 0 ] [ 6 ] ) . toEqual ( [
195+ { ...api . append . mock . calls [ 0 ] [ 3 ] [ 0 ] , status : 'error' } ,
196+ ] )
197+ } )
198+
199+ it ( 'aborts an ambiguous progress send and cleans up once without replaying it' , async ( ) => {
200+ const { stream, controller } = setup ( )
201+ await stream . start ( )
202+ api . append . mockRejectedValueOnce ( new Error ( 'progress response lost' ) )
203+ await expect ( stream . onEvent ( toolCall ( ) ) ) . rejects . toThrow ( 'progress response lost' )
204+ expect ( controller . signal . aborted ) . toBe ( true )
205+ await expect ( stream . onEvent ( toolCall ( ) ) ) . rejects . toThrow ( 'progress response lost' )
206+ await stream . terminateAfterFailure ( )
207+ await stream . terminateAfterFailure ( )
208+ expect ( api . append ) . toHaveBeenCalledOnce ( )
209+ expect ( api . stop ) . toHaveBeenCalledOnce ( )
210+ expect ( api . stop . mock . calls [ 0 ] [ 6 ] ) . toEqual ( [
211+ { ...api . append . mock . calls [ 0 ] [ 3 ] [ 0 ] , status : 'error' } ,
212+ ] )
213+ } )
214+
215+ it ( 'does not send task updates after cancellation or revoked delivery authority' , async ( ) => {
216+ const { stream, controller, beforeDelivery } = setup ( )
217+ await stream . start ( )
218+ beforeDelivery . mockRejectedValueOnce ( new Error ( 'authority revoked' ) )
219+ await expect ( stream . onEvent ( toolCall ( ) ) ) . rejects . toThrow ( 'authority revoked' )
220+ expect ( controller . signal . aborted ) . toBe ( true )
221+ expect ( api . append ) . not . toHaveBeenCalled ( )
222+ const cancelled = setup ( )
223+ await cancelled . stream . start ( )
224+ cancelled . controller . abort ( new Error ( 'stopped' ) )
225+ await expect ( cancelled . stream . onEvent ( toolCall ( ) ) ) . rejects . toThrow ( 'stopped' )
226+ expect ( api . append ) . not . toHaveBeenCalled ( )
227+ } )
228+ } )
229+
83230describe ( 'Slack Assistant delivery' , ( ) => {
84231 it ( 'withholds split connection tags, delivers validated controls, and leaves a visible next step' , async ( ) => {
85232 const deliver = vi . fn ( ) . mockResolvedValue ( undefined )
@@ -249,7 +396,8 @@ describe('Slack Assistant delivery', () => {
249396 type : 'section' ,
250397 text : { type : 'plain_text' , text : 'I couldn’t complete this search. Please try again.' } ,
251398 } ,
252- ]
399+ ] ,
400+ [ ]
253401 )
254402 expect ( api . append ) . not . toHaveBeenCalled ( )
255403 } )
0 commit comments