@@ -15,10 +15,16 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApprov
1515 } )
1616)
1717
18- const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi . hoisted ( ( ) => ( {
18+ const {
19+ upsertAsyncToolCall,
20+ markAsyncToolRunning,
21+ completeAsyncToolCall,
22+ claimWorkflowToolExecution,
23+ } = vi . hoisted ( ( ) => ( {
1924 upsertAsyncToolCall : vi . fn ( ) ,
2025 markAsyncToolRunning : vi . fn ( ) ,
2126 completeAsyncToolCall : vi . fn ( ) ,
27+ claimWorkflowToolExecution : vi . fn ( ) . mockResolvedValue ( null ) ,
2228} ) )
2329
2430const { waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion } =
@@ -56,6 +62,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({
5662 upsertAsyncToolCall,
5763 markAsyncToolRunning,
5864 completeAsyncToolCall,
65+ claimWorkflowToolExecution,
5966} ) )
6067
6168vi . mock ( '@/lib/copilot/request/tools/client' , ( ) => ( {
@@ -578,11 +585,13 @@ describe('sse-handlers tool lifecycle', () => {
578585 await sleep ( 0 )
579586 await Promise . allSettled ( context . pendingToolPromises . values ( ) )
580587
588+ // The waiter always receives a signal now: the server fallback needs a
589+ // handle to cancel its own wait if it ends up running the tool itself.
581590 expect ( waitForWorkflowToolCompletion ) . toHaveBeenCalledWith ( {
582591 toolCallId : 'tool-background' ,
583592 workflowId : 'workflow-1' ,
584593 timeoutMs : 1000 ,
585- abortSignal : undefined ,
594+ abortSignal : expect . any ( AbortSignal ) ,
586595 registry : execContext . resolvedSecretTraceRegistry ,
587596 } )
588597 expect ( onEvent ) . toHaveBeenCalledWith (
@@ -644,6 +653,116 @@ describe('sse-handlers tool lifecycle', () => {
644653 )
645654 } )
646655
656+ it ( 'runs a workflow tool server-side when no browser picks it up' , async ( ) => {
657+ // Nobody claims it, the wait expires, and the server wins the claim.
658+ waitForWorkflowToolCompletion . mockResolvedValue ( null )
659+ claimWorkflowToolExecution . mockResolvedValueOnce ( { toolCallId : 'tool-unclaimed' } )
660+ executeTool . mockResolvedValueOnce ( { success : true , output : { ran : 'on-server' } } )
661+ const onEvent = vi . fn ( )
662+
663+ await sseHandlers . tool (
664+ {
665+ type : MothershipStreamV1EventType . tool ,
666+ payload : {
667+ toolCallId : 'tool-unclaimed' ,
668+ toolName : 'run_workflow' ,
669+ arguments : { workflowId : 'workflow-1' } ,
670+ executor : MothershipStreamV1ToolExecutor . client ,
671+ mode : MothershipStreamV1ToolMode . async ,
672+ phase : MothershipStreamV1ToolPhase . call ,
673+ } ,
674+ } satisfies StreamEvent ,
675+ context ,
676+ execContext ,
677+ { onEvent, interactive : true , timeout : 1 }
678+ )
679+
680+ await Promise . allSettled ( context . pendingToolPromises . values ( ) )
681+
682+ // Regression guard: the wait sets the call to 'executing' before parking,
683+ // and executeToolAndReport short-circuits anything already 'executing'. If
684+ // the handoff stops resetting the status, executeTool is never reached and
685+ // the workflow silently does not run.
686+ expect ( executeTool ) . toHaveBeenCalled ( )
687+ expect ( executeTool . mock . calls . at ( - 1 ) ?. [ 0 ] ) . toBe ( 'run_workflow' )
688+ // The claimed execution id must reach the handler so the run is attributable.
689+ expect ( executeTool . mock . calls . at ( - 1 ) ?. [ 2 ] ?. boundWorkflowExecutionId ) . toBeTruthy ( )
690+
691+ const workflowResults = onEvent . mock . calls
692+ . map ( ( [ event ] ) => event )
693+ . filter (
694+ ( event ) =>
695+ event ?. type === MothershipStreamV1EventType . tool &&
696+ event . payload ?. toolCallId === 'tool-unclaimed' &&
697+ event . payload ?. phase === MothershipStreamV1ToolPhase . result
698+ )
699+ // Exactly one result, from the sim path — no client-flavored duplicate on top.
700+ expect ( workflowResults ) . toHaveLength ( 1 )
701+ expect ( workflowResults [ 0 ] . payload . executor ) . toBe ( MothershipStreamV1ToolExecutor . sim )
702+ } )
703+
704+ it ( 'claims and runs the same workflow when the call omits an explicit workflowId' , async ( ) => {
705+ // The waiter resolves the target via resolveWorkflowToolTargetId(args, ctx)
706+ // while the handler resolves it as params.workflowId || context.workflowId.
707+ // If those two ever diverge, the fallback would claim one workflow and run
708+ // another.
709+ waitForWorkflowToolCompletion . mockResolvedValue ( null )
710+ claimWorkflowToolExecution . mockResolvedValueOnce ( { toolCallId : 'tool-implicit-workflow' } )
711+ executeTool . mockResolvedValueOnce ( { success : true , output : { } } )
712+
713+ await sseHandlers . tool (
714+ {
715+ type : MothershipStreamV1EventType . tool ,
716+ payload : {
717+ toolCallId : 'tool-implicit-workflow' ,
718+ toolName : 'run_workflow' ,
719+ arguments : { } ,
720+ executor : MothershipStreamV1ToolExecutor . client ,
721+ mode : MothershipStreamV1ToolMode . async ,
722+ phase : MothershipStreamV1ToolPhase . call ,
723+ } ,
724+ } satisfies StreamEvent ,
725+ context ,
726+ execContext ,
727+ { onEvent : vi . fn ( ) , interactive : true , timeout : 1 }
728+ )
729+
730+ await Promise . allSettled ( context . pendingToolPromises . values ( ) )
731+
732+ expect ( waitForWorkflowToolCompletion ) . toHaveBeenCalledWith (
733+ expect . objectContaining ( { workflowId : 'workflow-1' } )
734+ )
735+ expect ( executeTool . mock . calls . at ( - 1 ) ?. [ 2 ] ?. workflowId ) . toBe ( 'workflow-1' )
736+ } )
737+
738+ it ( 'does not run a workflow tool server-side when a browser holds the claim' , async ( ) => {
739+ waitForWorkflowToolCompletion . mockResolvedValue ( null )
740+ claimWorkflowToolExecution . mockResolvedValueOnce ( null )
741+ executeTool . mockClear ( )
742+ const onEvent = vi . fn ( )
743+
744+ await sseHandlers . tool (
745+ {
746+ type : MothershipStreamV1EventType . tool ,
747+ payload : {
748+ toolCallId : 'tool-claimed-elsewhere' ,
749+ toolName : 'run_workflow' ,
750+ arguments : { workflowId : 'workflow-1' } ,
751+ executor : MothershipStreamV1ToolExecutor . client ,
752+ mode : MothershipStreamV1ToolMode . async ,
753+ phase : MothershipStreamV1ToolPhase . call ,
754+ } ,
755+ } satisfies StreamEvent ,
756+ context ,
757+ execContext ,
758+ { onEvent, interactive : true , timeout : 1 }
759+ )
760+
761+ await Promise . allSettled ( context . pendingToolPromises . values ( ) )
762+
763+ expect ( executeTool ) . not . toHaveBeenCalled ( )
764+ } )
765+
647766 it ( 'waits for the desktop client when a static VFS read is explicitly user-local' , async ( ) => {
648767 waitForClientToolCompletion . mockResolvedValueOnce ( {
649768 status : 'success' ,
0 commit comments