@@ -426,6 +426,195 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => {
426426 ) ;
427427 expect ( stale ) . toEqual ( [ ] ) ;
428428 } ) ;
429+
430+ function keeperManageTasksTurn ( ) : ReactorInboundEvent {
431+ return {
432+ type : "inference.done" ,
433+ turn : {
434+ role : "assistant" ,
435+ model : "test" ,
436+ timestamp : 0 ,
437+ content : [
438+ {
439+ type : "tool_call" ,
440+ id : "manage-tasks" ,
441+ name : "manage_tasks" ,
442+ arguments : {
443+ action : "create" ,
444+ tasks : [ { id : "t1" , title : "work" , status : "doing" } ] ,
445+ } ,
446+ } ,
447+ ] ,
448+ } ,
449+ usage : { input : 0 , output : 0 } ,
450+ source : "test" ,
451+ } as unknown as ReactorInboundEvent ;
452+ }
453+
454+ function keeperManageTasksPlusSubmitTurn ( ) : ReactorInboundEvent {
455+ return {
456+ type : "inference.done" ,
457+ turn : {
458+ role : "assistant" ,
459+ model : "test" ,
460+ timestamp : 0 ,
461+ content : [
462+ {
463+ type : "tool_call" ,
464+ id : "manage-tasks" ,
465+ name : "manage_tasks" ,
466+ arguments : {
467+ action : "create" ,
468+ tasks : [ { id : "t1" , title : "work" , status : "doing" } ] ,
469+ } ,
470+ } ,
471+ {
472+ type : "tool_call" ,
473+ id : "submit-1" ,
474+ name : "submit_output" ,
475+ arguments : { step : "step-1" } ,
476+ } ,
477+ ] ,
478+ } ,
479+ usage : { input : 0 , output : 0 } ,
480+ source : "test" ,
481+ } as unknown as ReactorInboundEvent ;
482+ }
483+
484+ function keeperTextTurn ( ) : ReactorInboundEvent {
485+ return {
486+ type : "inference.done" ,
487+ turn : {
488+ role : "assistant" ,
489+ model : "test" ,
490+ timestamp : 0 ,
491+ content : [ { type : "text" , text : "all set" } ] ,
492+ } ,
493+ usage : { input : 0 , output : 0 } ,
494+ source : "test" ,
495+ } as unknown as ReactorInboundEvent ;
496+ }
497+
498+ function keeperTasksChanged ( actions : ReactorAction [ ] ) : ReactorAction [ ] {
499+ return actions . filter (
500+ ( a ) =>
501+ a . type === "emit" &&
502+ ( a as { eventType ?: string } ) . eventType === CHAT_TASKS_CHANGED_EVENT ,
503+ ) ;
504+ }
505+
506+ // CL-7992 K1: every coordinator rail consulted on the turn boundary
507+ // rethrows instead of degrading — a throwing rail rejects decide() and
508+ // the next turn carries no stale task-change notifications.
509+ test . each ( [ "isActive" , "currentStepIsGate" , "currentStepId" ] as const ) (
510+ "a throwing %s rail rejects the inference turn without leaking task-change emits" ,
511+ async ( rail ) => {
512+ const failure = new Error ( `${ rail } exploded` ) ;
513+ const coordinator = {
514+ isActive : ( ) => {
515+ if ( rail === "isActive" ) throw failure ;
516+ return true ;
517+ } ,
518+ currentStepIsGate : ( ) => {
519+ if ( rail === "currentStepIsGate" ) throw failure ;
520+ return false ;
521+ } ,
522+ currentStepId : ( ) => {
523+ if ( rail === "currentStepId" ) throw failure ;
524+ return null ;
525+ } ,
526+ directive : ( ) => null ,
527+ handleToolDone : ( ) => false ,
528+ } as unknown as WorkflowCoordinator ;
529+ const director = createChatDirector ( "system" , [ ] , { } ) ;
530+ director . setWorkflowCoordinator ( coordinator ) ;
531+ const capabilities = makeCapabilities ( ) ;
532+
533+ // The step-id rail only runs past a terminal base action, so it
534+ // throws on a text turn; the earlier rails throw on a manage_tasks
535+ // turn after it queues its task-change notification.
536+ const triggering =
537+ rail === "currentStepId" ? keeperTextTurn ( ) : keeperManageTasksTurn ( ) ;
538+ await expect (
539+ director . decide ( triggering , mockState , capabilities ) ,
540+ ) . rejects . toBe ( failure ) ;
541+
542+ director . setWorkflowCoordinator ( undefined ) ;
543+ const actions = actionsArray (
544+ await director . decide ( keeperTextTurn ( ) , mockState , capabilities ) ,
545+ ) ;
546+ expect ( keeperTasksChanged ( actions ) ) . toEqual ( [ ] ) ;
547+ } ,
548+ ) ;
549+
550+ // CL-7992 K2: a mid-turn failure outside the coordinator still rejects
551+ // the turn, but already-queued notifications survive for the next turn.
552+ test ( "a non-coordinator mid-turn failure preserves queued task-change emits for the next turn" , async ( ) => {
553+ const failure = new Error ( "tool execution exploded" ) ;
554+ const director = createChatDirector ( "system" , [ ] , { } ) ;
555+ const capabilities = makeCapabilities ( ) ;
556+ const executing : ReactorCapabilities = {
557+ ...capabilities ,
558+ executeTools : ( ) : ReactorAction => {
559+ throw failure ;
560+ } ,
561+ } ;
562+
563+ await expect (
564+ director . decide ( keeperManageTasksTurn ( ) , mockState , executing ) ,
565+ ) . rejects . toBe ( failure ) ;
566+
567+ const actions = actionsArray (
568+ await director . decide ( keeperTextTurn ( ) , mockState , capabilities ) ,
569+ ) ;
570+ expect ( keeperTasksChanged ( actions ) ) . toHaveLength ( 1 ) ;
571+ } ) ;
572+
573+ // CL-7992 K2 variant: a throwing handleToolDone degrades (it takes no
574+ // rethrow parameter) instead of marking the turn stale, so a later
575+ // non-coordinator failure still preserves the queued notifications.
576+ test ( "a throwing handleToolDone degrades without dropping preserved task-change emits" , async ( ) => {
577+ let handleToolDoneSeen = false ;
578+ const coordinator = {
579+ isActive : ( ) => false ,
580+ currentStepIsGate : ( ) => false ,
581+ currentStepId : ( ) => null ,
582+ directive : ( ) => null ,
583+ handleToolDone : ( ) : boolean => {
584+ handleToolDoneSeen = true ;
585+ throw new Error ( "coordinator completion exploded" ) ;
586+ } ,
587+ } as unknown as WorkflowCoordinator ;
588+ const director = createChatDirector ( "system" , [ ] , { } ) ;
589+ director . setWorkflowCoordinator ( coordinator ) ;
590+ const capabilities = makeCapabilities ( ) ;
591+ const executing : ReactorCapabilities = {
592+ ...capabilities ,
593+ executeTools : ( ) : ReactorAction => {
594+ throw new Error ( "tool execution exploded" ) ;
595+ } ,
596+ } ;
597+
598+ await expect (
599+ director . decide ( keeperManageTasksPlusSubmitTurn ( ) , mockState , executing ) ,
600+ ) . rejects . toThrow ( "tool execution exploded" ) ;
601+
602+ const brokenState = {
603+ get turns ( ) : never {
604+ throw new Error ( "history unavailable" ) ;
605+ } ,
606+ } as unknown as ReactorState ;
607+ await expect (
608+ director . decide ( toolDoneEvent ( "submit-1" ) , brokenState , capabilities ) ,
609+ ) . rejects . toThrow ( "history unavailable" ) ;
610+ expect ( handleToolDoneSeen ) . toBe ( true ) ;
611+
612+ director . setWorkflowCoordinator ( undefined ) ;
613+ const actions = actionsArray (
614+ await director . decide ( keeperTextTurn ( ) , mockState , capabilities ) ,
615+ ) ;
616+ expect ( keeperTasksChanged ( actions ) ) . toHaveLength ( 1 ) ;
617+ } ) ;
429618} ) ;
430619
431620// CL-7973: the director's live source id (which stamps retry decisions so a
0 commit comments