99 getAsyncToolCall,
1010 getRunSegment,
1111 completeAsyncToolCall,
12+ completeClaimedAsyncToolCall,
1213 completePendingAsyncToolCall,
1314 detachAsyncToolCall,
1415 publishToolConfirmation,
@@ -18,6 +19,7 @@ const {
1819 getAsyncToolCall : vi . fn ( ) ,
1920 getRunSegment : vi . fn ( ) ,
2021 completeAsyncToolCall : vi . fn ( ) ,
22+ completeClaimedAsyncToolCall : vi . fn ( ) ,
2123 completePendingAsyncToolCall : vi . fn ( ) ,
2224 detachAsyncToolCall : vi . fn ( ) ,
2325 publishToolConfirmation : vi . fn ( ) ,
@@ -31,6 +33,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({
3133 getAsyncToolCall,
3234 getRunSegment,
3335 completeAsyncToolCall,
36+ completeClaimedAsyncToolCall,
3437 completePendingAsyncToolCall,
3538 detachAsyncToolCall,
3639 getClaimedWorkflowExecutionId : ( claimedBy ?: string | null ) =>
@@ -75,6 +78,7 @@ describe('Copilot Confirm API Route', () => {
7578 workflowId : 'workflow-from-run' ,
7679 } )
7780 completeAsyncToolCall . mockResolvedValue ( existingRow )
81+ completeClaimedAsyncToolCall . mockResolvedValue ( existingRow )
7882 completePendingAsyncToolCall . mockResolvedValue ( existingRow )
7983 detachAsyncToolCall . mockResolvedValue ( existingRow )
8084 encryptSecret . mockResolvedValue ( { encrypted : 'sealed-client-result' , iv : 'iv' } )
@@ -328,12 +332,95 @@ describe('Copilot Confirm API Route', () => {
328332 expect ( response . status ) . toBe ( 404 )
329333 expect ( await response . json ( ) ) . toEqual ( { error : 'Pending client tool call not found' } )
330334 expect ( completePendingAsyncToolCall ) . toHaveBeenCalledOnce ( )
335+ expect ( completeClaimedAsyncToolCall ) . not . toHaveBeenCalled ( )
331336 expect ( completeAsyncToolCall ) . not . toHaveBeenCalled ( )
332337 expect ( detachAsyncToolCall ) . not . toHaveBeenCalled ( )
333338 expect ( publishToolConfirmation ) . not . toHaveBeenCalled ( )
334339 }
335340 )
336341
342+ it . each ( [
343+ [ 'browser_snapshot' , 'desktop-browser' ] ,
344+ [ 'terminal' , 'desktop-terminal' ] ,
345+ ] as const ) (
346+ 'settles an indeterminate pending %s result when the exact %s claim wins the race' ,
347+ async ( toolName , claimOwner ) => {
348+ getAsyncToolCall . mockResolvedValue ( {
349+ ...existingRow ,
350+ toolName,
351+ status : 'pending' ,
352+ } )
353+ completePendingAsyncToolCall . mockResolvedValueOnce ( null )
354+
355+ const response = await POST (
356+ createMockPostRequest ( {
357+ toolCallId : 'tool-call-123' ,
358+ status : 'error' ,
359+ message : 'untrusted page-exit message' ,
360+ data : { outcomeUnknown : true , doNotRetry : true , untrusted : 'discard me' } ,
361+ } )
362+ )
363+
364+ expect ( response . status ) . toBe ( 200 )
365+ expect ( completePendingAsyncToolCall ) . toHaveBeenCalledOnce ( )
366+ expect ( completeClaimedAsyncToolCall ) . toHaveBeenCalledWith (
367+ {
368+ toolCallId : 'tool-call-123' ,
369+ status : 'failed' ,
370+ result : { __sealedClientToolCompletionV1 : 'sealed-client-result' } ,
371+ error : 'Tool failed' ,
372+ } ,
373+ claimOwner
374+ )
375+ expect ( encryptSecret ) . toHaveBeenCalledWith ( expect . stringContaining ( '"outcomeUnknown":true' ) )
376+ expect ( encryptSecret ) . toHaveBeenCalledWith ( expect . not . stringContaining ( 'discard me' ) )
377+ expect ( publishToolConfirmation ) . toHaveBeenCalledOnce ( )
378+ }
379+ )
380+
381+ it ( 'does not publish when another terminal transition wins indeterminate claim reconciliation' , async ( ) => {
382+ getAsyncToolCall . mockResolvedValue ( {
383+ ...existingRow ,
384+ toolName : 'browser_snapshot' ,
385+ status : 'pending' ,
386+ } )
387+ completePendingAsyncToolCall . mockResolvedValueOnce ( null )
388+ completeClaimedAsyncToolCall . mockResolvedValueOnce ( null )
389+
390+ const response = await POST (
391+ createMockPostRequest ( {
392+ toolCallId : 'tool-call-123' ,
393+ status : 'error' ,
394+ data : { outcomeUnknown : true , doNotRetry : true } ,
395+ } )
396+ )
397+
398+ expect ( response . status ) . toBe ( 404 )
399+ expect ( completeClaimedAsyncToolCall ) . toHaveBeenCalledWith ( expect . any ( Object ) , 'desktop-browser' )
400+ expect ( publishToolConfirmation ) . not . toHaveBeenCalled ( )
401+ } )
402+
403+ it ( 'returns 500 without publishing when exact claim reconciliation fails' , async ( ) => {
404+ getAsyncToolCall . mockResolvedValue ( {
405+ ...existingRow ,
406+ toolName : 'browser_snapshot' ,
407+ status : 'pending' ,
408+ } )
409+ completePendingAsyncToolCall . mockResolvedValueOnce ( null )
410+ completeClaimedAsyncToolCall . mockRejectedValueOnce ( new Error ( 'database unavailable' ) )
411+
412+ const response = await POST (
413+ createMockPostRequest ( {
414+ toolCallId : 'tool-call-123' ,
415+ status : 'error' ,
416+ data : { outcomeUnknown : true , doNotRetry : true } ,
417+ } )
418+ )
419+
420+ expect ( response . status ) . toBe ( 500 )
421+ expect ( publishToolConfirmation ) . not . toHaveBeenCalled ( )
422+ } )
423+
337424 it . each ( [ 'error' , 'cancelled' ] as const ) (
338425 'rejects a pending retired browser tool %s before a native claim' ,
339426 async ( status ) => {
0 commit comments