@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
22
33import { createSubAgentSessionStore } from "./session-store.js" ;
44import { forcedStopReport } from "./stop-policy.js" ;
5+ import { agentLaneIsLive , fleetProgress } from "../tui/agent-progress.js" ;
6+ import { formatAgentsPanel } from "../tui/chrome-state.js" ;
57
68import type { ReactorEmittedEvent } from "@intx/inference" ;
79
@@ -519,3 +521,132 @@ describe("CL-6943 reusable worker sessions", () => {
519521 expect ( store . get ( retained . id ) ) . toBeUndefined ( ) ;
520522 } ) ;
521523} ) ;
524+
525+ describe ( "interrupt stamps finishedAt once" , ( ) => {
526+ test ( "interruptOne sets finishedAt, keeps status running, and preserves tools" , ( ) => {
527+ let t = 1000 ;
528+ const store = createSubAgentSessionStore ( {
529+ now : ( ) => t ,
530+ createId : ( ) => "s-int" ,
531+ } ) ;
532+ const session = store . start ( {
533+ description : "looping" ,
534+ agentId : "explorer" ,
535+ brief : "b" ,
536+ retained : true ,
537+ } ) ;
538+ store . markRunning ( session . id ) ;
539+ store . appendEvent ( session . id , startCall ( 1 , "call-1" , "run_shell" ) ) ;
540+ store . registerInterrupt ( session . id , ( ) => { } ) ;
541+
542+ t = 2000 ;
543+ expect ( store . interruptOne ( session . id ) . ok ) . toBe ( true ) ;
544+ const after = store . get ( session . id ) ;
545+ expect ( after ?. status ) . toBe ( "running" ) ;
546+ expect ( after ?. lifecycleStatus ) . toBe ( "interrupted" ) ;
547+ expect ( after ?. finishedAt ) . toBe ( 2000 ) ;
548+ expect ( after ?. outstandingTools ) . toHaveLength ( 1 ) ;
549+ expect ( after ?. currentToolName ) . toBe ( "run_shell" ) ;
550+
551+ t = 3500 ;
552+ expect ( store . interruptOne ( session . id ) . ok ) . toBe ( true ) ;
553+ expect ( store . get ( session . id ) ?. finishedAt ) . toBe ( 2000 ) ;
554+ expect ( store . get ( session . id ) ?. status ) . toBe ( "running" ) ;
555+ expect ( store . get ( session . id ) ?. outstandingTools ) . toHaveLength ( 1 ) ;
556+ } ) ;
557+
558+ test ( "sendInputOne interrupt starts a live follow-up turn and keeps tools" , async ( ) => {
559+ let t = 1000 ;
560+ let finish : ( reply : string ) => void = ( ) => { } ;
561+ const store = createSubAgentSessionStore ( {
562+ now : ( ) => t ,
563+ createId : ( ) => "s-send" ,
564+ } ) ;
565+ const session = store . start ( {
566+ description : "looping" ,
567+ agentId : "explorer" ,
568+ brief : "b" ,
569+ retained : true ,
570+ } ) ;
571+ store . markRunning ( session . id ) ;
572+ store . appendEvent ( session . id , startCall ( 1 , "call-1" , "run_shell" ) ) ;
573+ store . registerInterrupt ( session . id , ( ) => { } ) ;
574+ store . registerFollowup (
575+ session . id ,
576+ ( ) =>
577+ new Promise < string > ( ( resolve ) => {
578+ finish = resolve ;
579+ } ) ,
580+ ) ;
581+
582+ t = 2500 ;
583+ const outcome = store . sendInputOne ( session . id , "stop that" , { interrupt : true } ) ;
584+ expect ( outcome ) . toEqual ( { ok : true , status : "interrupted" } ) ;
585+ const after = store . get ( session . id ) ;
586+ expect ( after ?. status ) . toBe ( "running" ) ;
587+ expect ( after ?. lifecycleStatus ) . toBe ( "running" ) ;
588+ expect ( after ?. finishedAt ) . toBeUndefined ( ) ;
589+ expect ( after ?. outstandingTools ) . toHaveLength ( 1 ) ;
590+
591+ t = 4000 ;
592+ expect ( store . interruptOne ( session . id ) . ok ) . toBe ( true ) ;
593+ expect ( store . get ( session . id ) ?. finishedAt ) . toBe ( 4000 ) ;
594+
595+ t = 5000 ;
596+ finish ( "later" ) ;
597+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
598+ expect ( store . get ( session . id ) ?. status ) . toBe ( "done" ) ;
599+ expect ( store . get ( session . id ) ?. lifecycleStatus ) . toBe ( "completed" ) ;
600+ expect ( store . get ( session . id ) ?. finishedAt ) . toBe ( 5000 ) ;
601+ } ) ;
602+
603+ test ( "a follow-up turn keeps the lane live past the linger window until it completes" , async ( ) => {
604+ let t = 1000 ;
605+ let finish : ( reply : string ) => void = ( ) => { } ;
606+ const store = createSubAgentSessionStore ( {
607+ now : ( ) => t ,
608+ createId : ( ) => "s-followup" ,
609+ } ) ;
610+ const session = store . start ( {
611+ description : "looping" ,
612+ agentId : "explorer" ,
613+ brief : "b" ,
614+ retained : true ,
615+ } ) ;
616+ store . markRunning ( session . id ) ;
617+ store . registerInterrupt ( session . id , ( ) => { } ) ;
618+ store . registerFollowup (
619+ session . id ,
620+ ( ) =>
621+ new Promise < string > ( ( resolve ) => {
622+ finish = resolve ;
623+ } ) ,
624+ ) ;
625+
626+ t = 2000 ;
627+ expect ( store . interruptOne ( session . id ) . ok ) . toBe ( true ) ;
628+ expect ( store . get ( session . id ) ?. finishedAt ) . toBe ( 2000 ) ;
629+
630+ t = 3000 ;
631+ const pending = store . followupOne ( session . id , "keep going" ) ;
632+
633+ t = 11_000 ;
634+ store . appendEvent ( session . id , startCall ( 1 , "call-1" , "run_shell" ) ) ;
635+ const live = store . list ( ) ;
636+ expect ( live [ 0 ] ?. lifecycleStatus ) . toBe ( "running" ) ;
637+ expect ( live [ 0 ] ?. finishedAt ) . toBeUndefined ( ) ;
638+ expect ( agentLaneIsLive ( live [ 0 ] ! ) ) . toBe ( true ) ;
639+ expect ( formatAgentsPanel ( live , undefined , t ) ?. [ 0 ] ?. status ) . toBe ( "running" ) ;
640+ expect ( fleetProgress ( live , t ) . running ) . toBe ( 1 ) ;
641+
642+ t = 12_000 ;
643+ finish ( "done" ) ;
644+ expect ( await pending ) . toEqual ( { ok : true , reply : "done" } ) ;
645+ const terminal = store . list ( ) ;
646+ expect ( terminal [ 0 ] ?. status ) . toBe ( "done" ) ;
647+ expect ( terminal [ 0 ] ?. lifecycleStatus ) . toBe ( "completed" ) ;
648+ expect ( terminal [ 0 ] ?. finishedAt ) . toBe ( 12_000 ) ;
649+ expect ( agentLaneIsLive ( terminal [ 0 ] ! ) ) . toBe ( false ) ;
650+ expect ( fleetProgress ( terminal , t ) . running ) . toBe ( 0 ) ;
651+ } ) ;
652+ } ) ;
0 commit comments