@@ -19,12 +19,15 @@ const {
1919 mockFetch,
2020 mockHandleExecutionCancelledConsole,
2121 mockHandleExecutionErrorConsole,
22+ mockIsExecutionStreamHttpError,
23+ mockIsRunToolActiveForWorkflow,
2224 mockLoadExecutionPointer,
2325 mockReconnect,
2426 mockRequestJson,
2527 mockResolveStartCandidates,
2628 mockSelectBestTrigger,
2729 mockUploadInternalFileSession,
30+ runToolReleaseListeners,
2831 terminalStoreState,
2932 workflowBlocks,
3033 workflowStoreState,
@@ -101,12 +104,15 @@ const {
101104 mockFetch : vi . fn ( ) ,
102105 mockHandleExecutionCancelledConsole : vi . fn ( ) ,
103106 mockHandleExecutionErrorConsole : vi . fn ( ) ,
107+ mockIsExecutionStreamHttpError : vi . fn ( ( ) => false ) ,
108+ mockIsRunToolActiveForWorkflow : vi . fn ( ( ) => false ) ,
104109 mockLoadExecutionPointer : vi . fn ( ) ,
105110 mockReconnect : vi . fn ( ) ,
106111 mockRequestJson : vi . fn ( ) ,
107112 mockResolveStartCandidates : vi . fn ( ) ,
108113 mockSelectBestTrigger : vi . fn ( ) ,
109114 mockUploadInternalFileSession : vi . fn ( ) ,
115+ runToolReleaseListeners : new Set < ( workflowId : string ) => void > ( ) ,
110116 terminalStoreState,
111117 workflowBlocks,
112118 workflowStoreState,
@@ -125,6 +131,16 @@ vi.mock('@/lib/api/client/request', () => ({
125131 requestJson : mockRequestJson ,
126132} ) )
127133
134+ vi . mock ( '@/lib/copilot/tools/client/run-tool-execution' , ( ) => ( {
135+ isRunToolActiveForWorkflow : mockIsRunToolActiveForWorkflow ,
136+ subscribeToRunToolRelease : ( listener : ( workflowId : string ) => void ) => {
137+ runToolReleaseListeners . add ( listener )
138+ return ( ) => {
139+ runToolReleaseListeners . delete ( listener )
140+ }
141+ } ,
142+ } ) )
143+
128144vi . mock ( '@/lib/api/contracts/workflows' , ( ) => ( {
129145 cancelWorkflowExecutionContract : { } ,
130146 workflowLogContract : { } ,
@@ -214,7 +230,7 @@ vi.mock('@/hooks/use-execution-stream', () => {
214230 class SSEStreamInterruptedError extends Error { }
215231
216232 return {
217- isExecutionStreamHttpError : ( ) => false ,
233+ isExecutionStreamHttpError : mockIsExecutionStreamHttpError ,
218234 SSEEventHandlerError,
219235 SSEStreamInterruptedError,
220236 useExecutionStream : ( ) => ( {
@@ -419,6 +435,8 @@ function resetWorkflowExecutionTestState() {
419435 mockBeginScopedExecution . mockReset ( ) . mockReturnValue ( { } )
420436 mockAdoptScopedExecution . mockReset ( ) . mockReturnValue ( undefined )
421437 mockEndScopedExecution . mockReset ( ) . mockReturnValue ( true )
438+ mockIsExecutionStreamHttpError . mockReset ( ) . mockReturnValue ( false )
439+ mockIsRunToolActiveForWorkflow . mockReset ( ) . mockReturnValue ( false )
422440 mockLoadExecutionPointer . mockReset ( ) . mockResolvedValue ( null )
423441 mockReconnect . mockReset ( ) . mockResolvedValue ( undefined )
424442 mockResolveStartCandidates . mockReset ( ) . mockReturnValue ( [ ] )
@@ -430,6 +448,36 @@ function resetWorkflowExecutionTestState() {
430448 executionStoreState . getWorkflowExecution . mockReturnValue ( idleExecution )
431449 executionStoreState . getCurrentExecutionId . mockReturnValue ( null )
432450 workflowStoreState . edges . length = 0
451+ runToolReleaseListeners . clear ( )
452+ }
453+
454+ /**
455+ * The store and pointer state a Sim run tool leaves behind the moment it starts
456+ * a run, before the server has acknowledged it: this is what the reconnect
457+ * flow reads as an orphaned run.
458+ */
459+ function primeRunToolOwnedExecution ( ) {
460+ terminalStoreState . _hasHydrated = true
461+ executionStoreState . getWorkflowExecution . mockReturnValue ( {
462+ ...executionStoreState . getWorkflowExecution ( ) ,
463+ status : 'running' ,
464+ isExecuting : true ,
465+ currentExecutionId : 'execution-1' ,
466+ } )
467+ executionStoreState . getCurrentExecutionId . mockReturnValue ( 'execution-1' )
468+ mockLoadExecutionPointer . mockResolvedValue ( {
469+ workflowId : 'workflow-1' ,
470+ executionId : 'execution-1' ,
471+ lastEventId : 0 ,
472+ } )
473+ }
474+
475+ /** The reconnect endpoint's answer while the run's buffer does not exist yet. */
476+ function rejectReconnectWithMissingRunBuffer ( ) {
477+ mockIsExecutionStreamHttpError . mockReturnValue ( true )
478+ mockReconnect . mockRejectedValue (
479+ Object . assign ( new Error ( 'Reconnect failed (404)' ) , { httpStatus : 404 } )
480+ )
433481}
434482
435483describe ( 'useWorkflowExecution lifecycle ownership' , ( ) => {
@@ -594,6 +642,118 @@ describe('useWorkflowExecution lifecycle ownership', () => {
594642 unmount ( )
595643 } )
596644
645+ it ( 'logs a Run Error when a reconnect for an unowned pointer finds no run buffer' , async ( ) => {
646+ primeRunToolOwnedExecution ( )
647+ rejectReconnectWithMissingRunBuffer ( )
648+
649+ const { unmount } = renderWorkflowExecutionHook ( )
650+ await act ( async ( ) => {
651+ await Promise . resolve ( )
652+ await Promise . resolve ( )
653+ } )
654+
655+ expect ( mockReconnect ) . toHaveBeenCalledTimes ( 1 )
656+ expect ( mockHandleExecutionErrorConsole . mock . calls [ 0 ] ) . toEqual (
657+ expect . arrayContaining ( [
658+ expect . objectContaining ( {
659+ executionId : 'execution-1' ,
660+ error : 'Execution state is no longer available after reconnect' ,
661+ } ) ,
662+ ] )
663+ )
664+ expect ( executionStoreState . setCurrentExecutionId ) . toHaveBeenCalledWith ( 'workflow-1' , null )
665+ expect ( executionStoreState . setIsExecuting ) . toHaveBeenCalledWith ( 'workflow-1' , false )
666+ expect ( mockClearExecutionPointer ) . toHaveBeenCalledWith ( 'workflow-1' )
667+
668+ unmount ( )
669+ } )
670+
671+ it ( 'leaves a run owned by a client run tool to its live stream instead of reconnecting' , async ( ) => {
672+ /*
673+ * Same state as above, but a Sim run tool in this tab still owns the run.
674+ * Its live stream is the source of truth, so reconnecting here would race
675+ * the run's own start (the 404 above, logged as a Run Error mid-run), tear
676+ * down the live run's store state, and clear the pointer the tool keeps
677+ * for reload recovery.
678+ */
679+ primeRunToolOwnedExecution ( )
680+ rejectReconnectWithMissingRunBuffer ( )
681+ mockIsRunToolActiveForWorkflow . mockReturnValue ( true )
682+
683+ const { unmount } = renderWorkflowExecutionHook ( )
684+ await act ( async ( ) => {
685+ await Promise . resolve ( )
686+ await Promise . resolve ( )
687+ } )
688+
689+ expect ( mockIsRunToolActiveForWorkflow ) . toHaveBeenCalledWith ( 'workflow-1' )
690+ expect ( mockReconnect ) . not . toHaveBeenCalled ( )
691+ expect ( mockHandleExecutionErrorConsole ) . not . toHaveBeenCalled ( )
692+ expect ( mockClearExecutionPointer ) . not . toHaveBeenCalled ( )
693+ expect ( executionStoreState . setCurrentExecutionId ) . not . toHaveBeenCalled ( )
694+ expect ( executionStoreState . setIsExecuting ) . not . toHaveBeenCalled ( )
695+ expect ( executionStoreState . setActiveBlocks ) . not . toHaveBeenCalled ( )
696+
697+ unmount ( )
698+ } )
699+
700+ it ( 'reconnects once the client run tool releases a run whose stream dropped' , async ( ) => {
701+ primeRunToolOwnedExecution ( )
702+ mockIsRunToolActiveForWorkflow . mockReturnValue ( true )
703+
704+ const { unmount } = renderWorkflowExecutionHook ( )
705+ await act ( async ( ) => {
706+ await Promise . resolve ( )
707+ await Promise . resolve ( )
708+ } )
709+ expect ( mockReconnect ) . not . toHaveBeenCalled ( )
710+ expect ( runToolReleaseListeners . size ) . toBeGreaterThan ( 0 )
711+
712+ /*
713+ * What the run tool leaves behind when it gives the run up: no current
714+ * execution, not executing, ownership released, and the pointer still
715+ * carrying the last event it persisted.
716+ */
717+ executionStoreState . getWorkflowExecution . mockReturnValue ( {
718+ ...executionStoreState . getWorkflowExecution ( ) ,
719+ status : 'idle' ,
720+ isExecuting : false ,
721+ currentExecutionId : null ,
722+ } )
723+ executionStoreState . getCurrentExecutionId . mockReturnValue ( null )
724+ mockLoadExecutionPointer . mockResolvedValue ( {
725+ workflowId : 'workflow-1' ,
726+ executionId : 'execution-1' ,
727+ lastEventId : 5 ,
728+ } )
729+ mockIsRunToolActiveForWorkflow . mockReturnValue ( false )
730+ await act ( async ( ) => {
731+ for ( const listener of runToolReleaseListeners ) listener ( 'workflow-2' )
732+ await Promise . resolve ( )
733+ await Promise . resolve ( )
734+ } )
735+ expect ( mockReconnect ) . not . toHaveBeenCalled ( )
736+
737+ await act ( async ( ) => {
738+ for ( const listener of runToolReleaseListeners ) listener ( 'workflow-1' )
739+ await Promise . resolve ( )
740+ await Promise . resolve ( )
741+ } )
742+
743+ expect ( mockReconnect ) . toHaveBeenCalledTimes ( 1 )
744+ expect ( mockReconnect ) . toHaveBeenCalledWith (
745+ expect . objectContaining ( {
746+ workflowId : 'workflow-1' ,
747+ executionId : 'execution-1' ,
748+ fromEventId : 5 ,
749+ } )
750+ )
751+ expect ( mockClearExecutionPointer ) . not . toHaveBeenCalled ( )
752+
753+ unmount ( )
754+ expect ( runToolReleaseListeners . size ) . toBe ( 0 )
755+ } )
756+
597757 it ( 'does not let delayed debug completion reset a replacement execution' , async ( ) => {
598758 const debugPersistenceExecution = { }
599759 const replacementPersistenceExecution = { }
0 commit comments