@@ -194,34 +194,31 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
194194 }
195195 } ) ;
196196
197- it ( "fires when there are in-flight users (no partial)" , async ( ) => {
198- const captured : { event ?: RecoveryBootEvent } = { } ;
197+ it ( "does NOT fire when there are in-flight users but no partial (graceful exit path)" , async ( ) => {
198+ // chat.requestUpgrade(), chat.endRun() before processing, and similar
199+ // graceful exits leave an unacknowledged user on session.in but no
200+ // partial assistant on session.out. That's not recovery — the next
201+ // run just dispatches the message normally.
202+ const onRecoveryBoot = vi . fn ( ) ;
199203 const model = new MockLanguageModelV3 ( {
200204 doStream : async ( ) => ( { stream : textStream ( "ok" ) } ) ,
201205 } ) ;
202206 const u1 = userMessage ( "buffered while dead" , "u-buffered" ) ;
203207 const agent = chat . agent ( {
204- id : "recovery-boot.inflight-users" ,
205- onRecoveryBoot : async ( event ) => {
206- captured . event = event ;
207- return { } ;
208- } ,
208+ id : "recovery-boot.inflight-users-no-partial" ,
209+ onRecoveryBoot,
209210 run : async ( { messages, signal } ) =>
210211 streamText ( { model, messages, abortSignal : signal } ) ,
211212 } ) ;
212213 const harness = mockChatAgent ( agent , {
213- chatId : "inflight-users" ,
214+ chatId : "inflight-users-no-partial " ,
214215 continuation : true ,
215216 previousRunId : "run_prior" ,
216217 } ) ;
217218 harness . seedSessionInTail ( [ u1 as never ] ) ;
218219 try {
219220 await new Promise ( ( r ) => setTimeout ( r , 50 ) ) ;
220- expect ( captured . event ) . toBeDefined ( ) ;
221- expect ( captured . event ! . inFlightUsers ) . toHaveLength ( 1 ) ;
222- expect ( captured . event ! . inFlightUsers [ 0 ] ! . id ) . toBe ( "u-buffered" ) ;
223- expect ( captured . event ! . partialAssistant ) . toBeUndefined ( ) ;
224- expect ( captured . event ! . pendingToolCalls ) . toEqual ( [ ] ) ;
221+ expect ( onRecoveryBoot ) . not . toHaveBeenCalled ( ) ;
225222 } finally {
226223 await harness . close ( ) ;
227224 }
@@ -316,6 +313,7 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
316313 return { stream : textStream ( `reply ${ turnCount } ` ) } ;
317314 } ,
318315 } ) ;
316+ const partial = assistantMessage ( "partial answer" , "a-partial" ) ;
319317 const u1 = userMessage ( "buffered" , "u-1" ) ;
320318 const agent = chat . agent ( {
321319 id : "recovery-boot.suppress-dispatch" ,
@@ -328,6 +326,7 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
328326 continuation : true ,
329327 previousRunId : "run_prior" ,
330328 } ) ;
329+ harness . seedSessionOutPartial ( partial as never ) ;
331330 harness . seedSessionInTail ( [ u1 as never ] ) ;
332331 try {
333332 // No turn should fire from the boot-injected queue.
@@ -345,6 +344,7 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
345344 doStream : async ( ) => ( { stream : textStream ( "acked" ) } ) ,
346345 } ) ;
347346 const custom = assistantMessage ( "custom-recovered-history" , "a-custom" ) ;
347+ const partial = assistantMessage ( "partial" , "a-partial" ) ;
348348 const u1 = userMessage ( "buffered" , "u-1" ) ;
349349 let observedMessageCount = 0 ;
350350 const agent = chat . agent ( {
@@ -364,6 +364,7 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
364364 continuation : true ,
365365 previousRunId : "run_prior" ,
366366 } ) ;
367+ harness . seedSessionOutPartial ( partial as never ) ;
367368 harness . seedSessionInTail ( [ u1 as never ] ) ;
368369 try {
369370 await new Promise ( ( r ) => setTimeout ( r , 50 ) ) ;
@@ -412,7 +413,9 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
412413 return { stream : textStream ( "ok" ) } ;
413414 } ,
414415 } ) ;
415- const u1 = userMessage ( "buffered" , "u-1" ) ;
416+ const partial = assistantMessage ( "partial" , "a-partial" ) ;
417+ const u1 = userMessage ( "buffered original" , "u-1" ) ;
418+ const u2 = userMessage ( "followup" , "u-2" ) ;
416419 const agent = chat . agent ( {
417420 id : "recovery-boot.before-boot" ,
418421 onRecoveryBoot : async ( ) : Promise < RecoveryBootResult > => ( {
@@ -428,7 +431,9 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
428431 continuation : true ,
429432 previousRunId : "run_prior" ,
430433 } ) ;
431- harness . seedSessionInTail ( [ u1 as never ] ) ;
434+ harness . seedSessionOutPartial ( partial as never ) ;
435+ // Two users — smart default consumes u1 into the chain, leaves u2 for dispatch
436+ harness . seedSessionInTail ( [ u1 as never , u2 as never ] ) ;
432437 try {
433438 await new Promise ( ( r ) => setTimeout ( r , 50 ) ) ;
434439 expect ( order ) . toEqual ( [ "beforeBoot" , "turn" ] ) ;
@@ -445,7 +450,9 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
445450 return { stream : textStream ( "ok" ) } ;
446451 } ,
447452 } ) ;
448- const u1 = userMessage ( "buffered" , "u-1" ) ;
453+ const partial = assistantMessage ( "partial" , "a-partial" ) ;
454+ const u1 = userMessage ( "buffered original" , "u-1" ) ;
455+ const u2 = userMessage ( "followup" , "u-2" ) ;
449456 const warnSpy = vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } ) ;
450457 const agent = chat . agent ( {
451458 id : "recovery-boot.hook-throws" ,
@@ -460,7 +467,9 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
460467 continuation : true ,
461468 previousRunId : "run_prior" ,
462469 } ) ;
463- harness . seedSessionInTail ( [ u1 as never ] ) ;
470+ harness . seedSessionOutPartial ( partial as never ) ;
471+ // Two users so smart default leaves u2 to dispatch (u1 spliced into chain)
472+ harness . seedSessionInTail ( [ u1 as never , u2 as never ] ) ;
464473 try {
465474 await new Promise ( ( r ) => setTimeout ( r , 100 ) ) ;
466475 // Default behavior: the in-flight user is re-dispatched as a turn
0 commit comments