@@ -2063,6 +2063,73 @@ describe('runCopilotLifecycle', () => {
20632063 }
20642064 } )
20652065
2066+ it ( 'cancels promptly while a sequential tool promise remains unsettled' , async ( ) => {
2067+ vi . useFakeTimers ( )
2068+ try {
2069+ const controller = new AbortController ( )
2070+ const fetchUrls : string [ ] = [ ]
2071+ let capturedContext : StreamingContext | null = null
2072+ const executionContext : ExecutionContext = {
2073+ userId : 'user-1' ,
2074+ workflowId : '' ,
2075+ workspaceId : 'ws-1' ,
2076+ chatId : 'chat-1' ,
2077+ }
2078+
2079+ mockPendingToolWaitBudgetMs . mockReturnValue ( 3_600_000 )
2080+ mockRunStreamLoop . mockImplementationOnce (
2081+ async ( fetchUrl : string , _fetchOptions : RequestInit , context : StreamingContext ) => {
2082+ fetchUrls . push ( fetchUrl )
2083+ capturedContext = context
2084+ context . toolCalls . set ( 'tool-hung' , {
2085+ id : 'tool-hung' ,
2086+ name : 'terminal' ,
2087+ status : 'awaiting_approval' ,
2088+ } )
2089+ context . pendingToolPromises . set ( 'tool-hung' , new Promise ( ( ) => { } ) )
2090+ context . awaitingAsyncContinuation = {
2091+ checkpointId : 'ckpt-1' ,
2092+ pendingToolCallIds : [ 'tool-hung' ] ,
2093+ }
2094+ }
2095+ )
2096+
2097+ const lifecycle = runCopilotLifecycle (
2098+ { message : 'hello' , messageId : 'stream-aborted-tool-wait' } ,
2099+ {
2100+ userId : 'user-1' ,
2101+ workspaceId : 'ws-1' ,
2102+ chatId : 'chat-1' ,
2103+ executionId : 'exec-1' ,
2104+ runId : 'run-1' ,
2105+ executionContext,
2106+ abortSignal : controller . signal ,
2107+ }
2108+ )
2109+
2110+ await vi . advanceTimersByTimeAsync ( 0 )
2111+ expect ( mockPendingToolWaitBudgetMs ) . toHaveBeenCalled ( )
2112+ controller . abort ( 'user_stop' )
2113+ await vi . advanceTimersByTimeAsync ( 0 )
2114+ const result = await lifecycle
2115+
2116+ expect ( result . success ) . toBe ( false )
2117+ expect ( result . cancelled ) . toBe ( true )
2118+ expect ( fetchUrls ) . toEqual ( [ 'http://mothership.test/api/copilot' ] )
2119+ expect ( mockForceFailHungToolCall ) . not . toHaveBeenCalled ( )
2120+ expect ( capturedContext ?. toolCalls . get ( 'tool-hung' ) ) . toMatchObject ( {
2121+ status : MothershipStreamV1ToolOutcome . cancelled ,
2122+ error : 'Stopped by user' ,
2123+ } )
2124+
2125+ await vi . advanceTimersByTimeAsync ( 3_700_000 )
2126+ expect ( mockForceFailHungToolCall ) . not . toHaveBeenCalled ( )
2127+ expect ( fetchUrls ) . toEqual ( [ 'http://mothership.test/api/copilot' ] )
2128+ } finally {
2129+ vi . useRealTimers ( )
2130+ }
2131+ } )
2132+
20662133 it ( 'force-fails each hung tool on its own budget while awaiting a long approval' , async ( ) => {
20672134 vi . useFakeTimers ( )
20682135 try {
@@ -2420,6 +2487,57 @@ describe('runCopilotLifecycle', () => {
24202487 expect ( result . success ) . toBe ( true )
24212488 } )
24222489
2490+ it ( 'cancels promptly while a per-subagent tool promise remains unsettled' , async ( ) => {
2491+ const controller = new AbortController ( )
2492+ const addAbortListener = vi . spyOn ( controller . signal , 'addEventListener' )
2493+ const fetchUrls : string [ ] = [ ]
2494+ let capturedContext : StreamingContext | null = null
2495+ mockRunStreamLoop . mockImplementationOnce (
2496+ async ( fetchUrl : string , _fetchOptions : RequestInit , context : StreamingContext ) => {
2497+ fetchUrls . push ( fetchUrl )
2498+ capturedContext = context
2499+ context . toolCalls . set ( 'tool-hung' , {
2500+ id : 'tool-hung' ,
2501+ name : 'read' ,
2502+ status : 'executing' ,
2503+ } )
2504+ context . pendingToolPromises . set ( 'tool-hung' , new Promise ( ( ) => { } ) )
2505+ context . awaitingAsyncContinuation = {
2506+ checkpointId : 'cp-root' ,
2507+ pendingToolCallIds : [ 'tool-hung' ] ,
2508+ frames : [
2509+ {
2510+ parentToolCallId : 'subagent-file' ,
2511+ parentToolName : 'file' ,
2512+ pendingToolIds : [ 'tool-hung' ] ,
2513+ checkpointId : 'cp-file' ,
2514+ } ,
2515+ ] ,
2516+ }
2517+ }
2518+ )
2519+
2520+ const lifecycle = runCopilotLifecycle (
2521+ { message : 'hello' , messageId : 'stream-aborted-subagent-wait' } ,
2522+ { userId : 'user-1' , workspaceId : 'ws-1' , abortSignal : controller . signal }
2523+ )
2524+
2525+ await vi . waitFor ( ( ) => {
2526+ expect ( addAbortListener ) . toHaveBeenCalledWith ( 'abort' , expect . any ( Function ) , { once : true } )
2527+ } )
2528+ controller . abort ( 'user_stop' )
2529+ const result = await lifecycle
2530+
2531+ expect ( result . success ) . toBe ( false )
2532+ expect ( result . cancelled ) . toBe ( true )
2533+ expect ( fetchUrls ) . toEqual ( [ 'http://mothership.test/api/copilot' ] )
2534+ expect ( mockForceFailHungToolCall ) . not . toHaveBeenCalled ( )
2535+ expect ( capturedContext ?. toolCalls . get ( 'tool-hung' ) ) . toMatchObject ( {
2536+ status : MothershipStreamV1ToolOutcome . cancelled ,
2537+ error : 'Stopped by user' ,
2538+ } )
2539+ } )
2540+
24232541 it ( 'classifies a Stop landing during a subagent fanout as cancelled' , async ( ) => {
24242542 // Guards the trap in the fanout fix: `wasAborted` is now isolated per leg, so
24252543 // a user Stop must still reach the turn — via the abort signal or the folded
0 commit comments