@@ -417,6 +417,114 @@ it.layer(NodeServices.layer)("session lifecycle CAS decider", (it) => {
417417 } ) ,
418418 ) ;
419419
420+ it . effect ( "starts a new turn instead of steering when a running session has no active turn" , ( ) =>
421+ Effect . gen ( function * ( ) {
422+ // Claude flips the session to "running" on its own system/status
423+ // notifications between turns, so status alone cannot prove a turn
424+ // exists. Steering nothing would hand the provider a turn the admission
425+ // gate can never correlate, and the user's message would vanish.
426+ const runningWithoutTurn = makeSession ( {
427+ status : "running" ,
428+ pendingTurnRequestId : undefined ,
429+ pendingTurnMessageId : undefined ,
430+ pendingTurnRequestedAt : undefined ,
431+ pendingTurnDeadlineAt : undefined ,
432+ pendingTurnSessionId : undefined ,
433+ activeTurnRequestId : undefined ,
434+ activeTurnId : null ,
435+ } ) ;
436+ const commandId = CommandId . make ( "cmd-running-without-turn" ) ;
437+ const decided = yield * decideOrchestrationCommand ( {
438+ command : {
439+ type : "thread.turn.start" ,
440+ commandId,
441+ threadId : THREAD_ID ,
442+ message : {
443+ messageId : MessageId . make ( "message-running-without-turn" ) ,
444+ role : "user" ,
445+ text : "nothing is running, start a turn" ,
446+ attachments : [ ] ,
447+ } ,
448+ modelSelection : { instanceId : INSTANCE_ID , model : "gpt-5.4" } ,
449+ runtimeMode : "full-access" ,
450+ interactionMode : "default" ,
451+ createdAt : NOW ,
452+ } ,
453+ readModel : makeReadModel ( runningWithoutTurn ) ,
454+ } ) ;
455+ const events = Array . isArray ( decided ) ? decided : [ decided ] ;
456+ expect ( events . map ( ( event ) => event . type ) ) . toEqual ( [
457+ "thread.message-sent" ,
458+ "thread.session-set" ,
459+ "thread.turn-start-requested" ,
460+ ] ) ;
461+ expect ( events [ 1 ] ) . toMatchObject ( {
462+ type : "thread.session-set" ,
463+ payload : {
464+ session : {
465+ status : "starting" ,
466+ pendingTurnRequestId : commandId ,
467+ activeTurnId : null ,
468+ } ,
469+ } ,
470+ } ) ;
471+ expect ( events [ 2 ] ) . toMatchObject ( {
472+ type : "thread.turn-start-requested" ,
473+ payload : { admissionIntent : { kind : "start" , expectedActiveTurnRequestId : null } } ,
474+ } ) ;
475+ } ) ,
476+ ) ;
477+
478+ it . effect ( "starts a new turn instead of steering a provider-initiated turn" , ( ) =>
479+ Effect . gen ( function * ( ) {
480+ // A turn the provider opened on its own (Claude continuing after a
481+ // background task) is running but was never admitted, so it has no
482+ // active request id. Steering it would tag the provider's next turn with
483+ // a request id the admission gate cannot correlate. Start exactly.
484+ const providerInitiated = makeSession ( {
485+ status : "running" ,
486+ pendingTurnRequestId : undefined ,
487+ pendingTurnMessageId : undefined ,
488+ pendingTurnRequestedAt : undefined ,
489+ pendingTurnDeadlineAt : undefined ,
490+ pendingTurnSessionId : undefined ,
491+ activeTurnRequestId : undefined ,
492+ activeTurnId : TurnId . make ( "turn-provider-initiated" ) ,
493+ } ) ;
494+ const commandId = CommandId . make ( "cmd-provider-initiated-turn" ) ;
495+ const decided = yield * decideOrchestrationCommand ( {
496+ command : {
497+ type : "thread.turn.start" ,
498+ commandId,
499+ threadId : THREAD_ID ,
500+ message : {
501+ messageId : MessageId . make ( "message-provider-initiated-turn" ) ,
502+ role : "user" ,
503+ text : "take over from the background continuation" ,
504+ attachments : [ ] ,
505+ } ,
506+ modelSelection : { instanceId : INSTANCE_ID , model : "gpt-5.4" } ,
507+ runtimeMode : "full-access" ,
508+ interactionMode : "default" ,
509+ createdAt : NOW ,
510+ } ,
511+ readModel : makeReadModel ( providerInitiated ) ,
512+ } ) ;
513+ const events = Array . isArray ( decided ) ? decided : [ decided ] ;
514+ expect ( events . map ( ( event ) => event . type ) ) . toEqual ( [
515+ "thread.message-sent" ,
516+ "thread.session-set" ,
517+ "thread.turn-start-requested" ,
518+ ] ) ;
519+ expect ( events [ 1 ] ) . toMatchObject ( {
520+ payload : { session : { status : "starting" , pendingTurnRequestId : commandId } } ,
521+ } ) ;
522+ expect ( events [ 2 ] ) . toMatchObject ( {
523+ payload : { admissionIntent : { kind : "start" } } ,
524+ } ) ;
525+ } ) ,
526+ ) ;
527+
420528 it . effect ( "captures the exact stop target and projects stopped atomically" , ( ) =>
421529 Effect . gen ( function * ( ) {
422530 const turnId = TurnId . make ( "turn-stop-target" ) ;
0 commit comments