@@ -2000,6 +2000,185 @@ describe("subagent registry seam flow", () => {
20002000 expect ( replacement ?. endedAt ) . toBeUndefined ( ) ;
20012001 } ) ;
20022002
2003+ it ( "keeps yield terminals paused when the lifecycle event also signals abort (#92448)" , async ( ) => {
2004+ // sessions_yield ends the turn by aborting the run signal, so a depth-1
2005+ // subagent's yield terminal can arrive carrying yielded plus aborted (or
2006+ // stopReason="aborted"). The event handler must still pause the run, not
2007+ // settle it `cancelled` and deliver a false notice to the requester.
2008+ mocks . callGateway . mockImplementation ( async ( request : { method ?: string } ) => {
2009+ if ( request . method === "agent.wait" ) {
2010+ return { status : "pending" } ;
2011+ }
2012+ return { } ;
2013+ } ) ;
2014+
2015+ const cases = [
2016+ { runId : "run-yield-stopreason-aborted" , extra : { stopReason : "aborted" } } ,
2017+ { runId : "run-yield-aborted-flag" , extra : { aborted : true } } ,
2018+ ] ;
2019+
2020+ for ( const testCase of cases ) {
2021+ mod . registerSubagentRun ( {
2022+ runId : testCase . runId ,
2023+ childSessionKey : `agent:main:subagent:${ testCase . runId } ` ,
2024+ requesterSessionKey : "agent:main:main" ,
2025+ requesterDisplayKey : "main" ,
2026+ task : "wait for child continuation" ,
2027+ cleanup : "keep" ,
2028+ } ) ;
2029+
2030+ const lastOnAgentEventCall = mocks . onAgentEvent . mock . calls [
2031+ mocks . onAgentEvent . mock . calls . length - 1
2032+ ] as unknown as
2033+ | [ ( evt : { runId : string ; stream : string ; data : Record < string , unknown > } ) => void ]
2034+ | undefined ;
2035+ const lifecycleHandler = lastOnAgentEventCall ?. [ 0 ] ;
2036+ expect ( lifecycleHandler ) . toBeTypeOf ( "function" ) ;
2037+
2038+ lifecycleHandler ?.( {
2039+ runId : testCase . runId ,
2040+ stream : "lifecycle" ,
2041+ data : {
2042+ phase : "end" ,
2043+ startedAt : 111 ,
2044+ endedAt : 222 ,
2045+ yielded : true ,
2046+ ...testCase . extra ,
2047+ } ,
2048+ } ) ;
2049+
2050+ await waitForFast ( ( ) => {
2051+ const run = mod
2052+ . listSubagentRunsForRequester ( "agent:main:main" )
2053+ . find ( ( entry ) => entry . runId === testCase . runId ) ;
2054+ expect ( run ?. pauseReason ) . toBe ( "sessions_yield" ) ;
2055+ expect ( run ?. outcome ?. status ) . not . toBe ( "error" ) ;
2056+ } ) ;
2057+ }
2058+
2059+ // Paused, never killed → no farewell/cancellation notice reaches the requester.
2060+ expect ( mocks . runSubagentAnnounceFlow ) . not . toHaveBeenCalled ( ) ;
2061+ } ) ;
2062+
2063+ it ( "cancels a pending grace timer when a yield follows an intermediate aborted terminal (#92448)" , async ( ) => {
2064+ // An earlier aborted terminal schedules a deferred kill grace timer; a
2065+ // following yield must clear it, or it fires and settles the now-paused run.
2066+ mocks . callGateway . mockImplementation ( async ( request : { method ?: string } ) => {
2067+ if ( request . method === "agent.wait" ) {
2068+ return { status : "pending" } ;
2069+ }
2070+ return { } ;
2071+ } ) ;
2072+
2073+ mod . registerSubagentRun ( {
2074+ runId : "run-yield-after-pending-timeout" ,
2075+ childSessionKey : "agent:main:subagent:pending-timeout" ,
2076+ requesterSessionKey : "agent:main:main" ,
2077+ requesterDisplayKey : "main" ,
2078+ task : "wait for child continuation" ,
2079+ cleanup : "keep" ,
2080+ } ) ;
2081+
2082+ const lastOnAgentEventCall = mocks . onAgentEvent . mock . calls [
2083+ mocks . onAgentEvent . mock . calls . length - 1
2084+ ] as unknown as
2085+ | [ ( evt : { runId : string ; stream : string ; data : Record < string , unknown > } ) => void ]
2086+ | undefined ;
2087+ const lifecycleHandler = lastOnAgentEventCall ?. [ 0 ] ;
2088+ expect ( lifecycleHandler ) . toBeTypeOf ( "function" ) ;
2089+
2090+ // Intermediate aborted terminal → schedules the deferred kill grace timer.
2091+ lifecycleHandler ?.( {
2092+ runId : "run-yield-after-pending-timeout" ,
2093+ stream : "lifecycle" ,
2094+ data : { phase : "end" , startedAt : 111 , endedAt : 222 , aborted : true } ,
2095+ } ) ;
2096+ // Yield terminal → must pause and cancel the pending grace timer.
2097+ lifecycleHandler ?.( {
2098+ runId : "run-yield-after-pending-timeout" ,
2099+ stream : "lifecycle" ,
2100+ data : { phase : "end" , startedAt : 111 , endedAt : 333 , yielded : true } ,
2101+ } ) ;
2102+
2103+ await waitForFast ( ( ) => {
2104+ const run = mod
2105+ . listSubagentRunsForRequester ( "agent:main:main" )
2106+ . find ( ( entry ) => entry . runId === "run-yield-after-pending-timeout" ) ;
2107+ expect ( run ?. pauseReason ) . toBe ( "sessions_yield" ) ;
2108+ } ) ;
2109+
2110+ // Advancing well past the 15s grace window must not undo the pause.
2111+ await vi . advanceTimersByTimeAsync ( 60_000 ) ;
2112+ const run = mod
2113+ . listSubagentRunsForRequester ( "agent:main:main" )
2114+ . find ( ( entry ) => entry . runId === "run-yield-after-pending-timeout" ) ;
2115+ expect ( run ?. pauseReason ) . toBe ( "sessions_yield" ) ;
2116+ expect ( run ?. outcome ?. status ) . not . toBe ( "error" ) ;
2117+ expect ( mocks . runSubagentAnnounceFlow ) . not . toHaveBeenCalled ( ) ;
2118+ } ) ;
2119+
2120+ it ( "cancels a pending grace timer when agent.wait observes the yield after an aborted terminal (#92448)" , async ( ) => {
2121+ let resolveWait : ( value : {
2122+ status : "ok" ;
2123+ startedAt : number ;
2124+ endedAt : number ;
2125+ yielded : true ;
2126+ } ) => void = ( ) => { } ;
2127+ const waitResult = new Promise < {
2128+ status : "ok" ;
2129+ startedAt : number ;
2130+ endedAt : number ;
2131+ yielded : true ;
2132+ } > ( ( resolve ) => {
2133+ resolveWait = resolve ;
2134+ } ) ;
2135+ mocks . callGateway . mockImplementation ( async ( request : { method ?: string } ) => {
2136+ if ( request . method === "agent.wait" ) {
2137+ return waitResult ;
2138+ }
2139+ return { } ;
2140+ } ) ;
2141+
2142+ mod . registerSubagentRun ( {
2143+ runId : "run-wait-yield-after-pending-timeout" ,
2144+ childSessionKey : "agent:main:subagent:pending-wait-timeout" ,
2145+ requesterSessionKey : "agent:main:main" ,
2146+ requesterDisplayKey : "main" ,
2147+ task : "wait for child continuation through wait" ,
2148+ cleanup : "keep" ,
2149+ } ) ;
2150+
2151+ const lastOnAgentEventCall = mocks . onAgentEvent . mock . calls [
2152+ mocks . onAgentEvent . mock . calls . length - 1
2153+ ] as unknown as
2154+ | [ ( evt : { runId : string ; stream : string ; data : Record < string , unknown > } ) => void ]
2155+ | undefined ;
2156+ const lifecycleHandler = lastOnAgentEventCall ?. [ 0 ] ;
2157+ expect ( lifecycleHandler ) . toBeTypeOf ( "function" ) ;
2158+
2159+ lifecycleHandler ?.( {
2160+ runId : "run-wait-yield-after-pending-timeout" ,
2161+ stream : "lifecycle" ,
2162+ data : { phase : "end" , startedAt : 111 , endedAt : 222 , aborted : true } ,
2163+ } ) ;
2164+ resolveWait ( { status : "ok" , startedAt : 111 , endedAt : 333 , yielded : true } ) ;
2165+
2166+ await waitForFast ( ( ) => {
2167+ const run = mod
2168+ . listSubagentRunsForRequester ( "agent:main:main" )
2169+ . find ( ( entry ) => entry . runId === "run-wait-yield-after-pending-timeout" ) ;
2170+ expect ( run ?. pauseReason ) . toBe ( "sessions_yield" ) ;
2171+ } ) ;
2172+
2173+ await vi . advanceTimersByTimeAsync ( 60_000 ) ;
2174+ const run = mod
2175+ . listSubagentRunsForRequester ( "agent:main:main" )
2176+ . find ( ( entry ) => entry . runId === "run-wait-yield-after-pending-timeout" ) ;
2177+ expect ( run ?. pauseReason ) . toBe ( "sessions_yield" ) ;
2178+ expect ( run ?. outcome ?. status ) . not . toBe ( "timeout" ) ;
2179+ expect ( mocks . runSubagentAnnounceFlow ) . not . toHaveBeenCalled ( ) ;
2180+ } ) ;
2181+
20032182 it ( "announces blocked agent.wait snapshots as errors instead of success" , async ( ) => {
20042183 mocks . callGateway . mockImplementation ( async ( request : { method ?: string } ) => {
20052184 if ( request . method === "agent.wait" ) {
0 commit comments