@@ -3190,6 +3190,246 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))(
31903190 } ) ,
31913191 ) ;
31923192
3193+ it . effect . each ( [ "woken" , "cancelled" ] as const ) (
3194+ "holds a follow-up open while the child's awaited background command runs (%s)" ,
3195+ ( ending ) =>
3196+ Effect . gen ( function * ( ) {
3197+ const orchestrator = yield * Orchestrator . OrchestratorV2 ;
3198+ const eventSink = yield * EventSink . EventSinkV2 ;
3199+ const now = yield * DateTime . now ;
3200+ const parentThreadId = ThreadId . make ( `thread:followup-command-parent-${ ending } ` ) ;
3201+ const projectId = ProjectId . make ( `project:followup-command-${ ending } ` ) ;
3202+ const parentRunId = RunId . make ( `run:followup-command-parent-${ ending } ` ) ;
3203+ const rootNodeId = NodeId . make ( `node:followup-command-parent-${ ending } ` ) ;
3204+ yield * seedParentWithTerminalTask ( {
3205+ threadId : parentThreadId ,
3206+ projectId,
3207+ runId : parentRunId ,
3208+ rootNodeId,
3209+ taskId : NodeId . make ( `node:followup-command-seed-${ ending } ` ) ,
3210+ deliveryState : "acknowledged" ,
3211+ now,
3212+ } ) ;
3213+ const child = yield * seedRestartCancelledChild ( {
3214+ parentThreadId,
3215+ projectId,
3216+ parentRunId,
3217+ rootNodeId,
3218+ name : `followup-command-child-${ ending } ` ,
3219+ completionWake : "always" ,
3220+ continuationPending : false ,
3221+ runStatus : "completed" ,
3222+ now,
3223+ } ) ;
3224+ yield * orchestrator . recoverDelegatedTasks ;
3225+
3226+ const followupMessageId = MessageId . make ( `message:followup-command-send-${ ending } ` ) ;
3227+ yield * orchestrator . dispatch ( {
3228+ type : "message.dispatch" ,
3229+ createdBy : "agent" ,
3230+ creationSource : "mcp" ,
3231+ commandId : CommandId . make ( `command:followup-command-send-${ ending } ` ) ,
3232+ threadId : child . childThreadId ,
3233+ senderThreadId : parentThreadId ,
3234+ messageId : followupMessageId ,
3235+ text : "Start the check in the background, wait on it, and reply when it ends." ,
3236+ attachments : [ ] ,
3237+ dispatchMode : { type : "queue_after_active" } ,
3238+ } ) ;
3239+ const followupRun = ( yield * orchestrator . getThreadProjection (
3240+ child . childThreadId ,
3241+ ) ) . runs . find ( ( run ) => run . userMessageId === followupMessageId ) ! ;
3242+ const followupTaskId = followupRun . delegatedTaskId ! ;
3243+ const parent = yield * orchestrator . getThreadProjection ( parentThreadId ) ;
3244+
3245+ // The child awaits a background command, replies with an interim note and ends its turn.
3246+ yield * eventSink . write ( {
3247+ commandId : reconcileCommandId ( `followup-command-turn-ended-${ ending } ` ) ,
3248+ events : [
3249+ {
3250+ id : EventId . make ( `event:followup-command-awaited-${ ending } ` ) ,
3251+ type : "provider-thread.updated" ,
3252+ threadId : child . childThreadId ,
3253+ driver,
3254+ providerInstanceId : modelSelection . instanceId ,
3255+ occurredAt : now ,
3256+ payload : {
3257+ ...parent . providerThreads [ 0 ] ! ,
3258+ id : ProviderThreadId . make ( `provider-thread:followup-command-child-${ ending } ` ) ,
3259+ appThreadId : child . childThreadId ,
3260+ pendingBackgroundTasks : [
3261+ { taskId : "sleep-90" , kind : "command" as const , awaited : true as const } ,
3262+ ] ,
3263+ } ,
3264+ } ,
3265+ {
3266+ id : EventId . make ( `event:followup-command-interim-${ ending } ` ) ,
3267+ type : "message.updated" ,
3268+ threadId : child . childThreadId ,
3269+ runId : followupRun . id ,
3270+ occurredAt : now ,
3271+ payload : {
3272+ id : MessageId . make ( `message:followup-command-interim-${ ending } ` ) ,
3273+ threadId : child . childThreadId ,
3274+ runId : followupRun . id ,
3275+ nodeId : null ,
3276+ role : "assistant" ,
3277+ text : "INTERIM" ,
3278+ attachments : [ ] ,
3279+ streaming : false ,
3280+ createdBy : "agent" ,
3281+ creationSource : "server" ,
3282+ createdAt : now ,
3283+ updatedAt : now ,
3284+ } ,
3285+ } ,
3286+ {
3287+ id : EventId . make ( `event:followup-command-run-completed-${ ending } ` ) ,
3288+ type : "run.updated" ,
3289+ threadId : child . childThreadId ,
3290+ runId : followupRun . id ,
3291+ occurredAt : now ,
3292+ payload : { ...followupRun , status : "completed" , startedAt : now , completedAt : now } ,
3293+ } ,
3294+ ] ,
3295+ } ) ;
3296+ yield * orchestrator . recoverDelegatedTasks ;
3297+
3298+ const held = ( yield * orchestrator . getThreadProjection ( parentThreadId ) ) . subagents . find (
3299+ ( task ) => task . id === followupTaskId ,
3300+ ) ;
3301+ assert . isNull (
3302+ held ?. result ?? null ,
3303+ "the follow-up closed with its interim reply while the awaited command still ran" ,
3304+ ) ;
3305+ assert . equal ( held ?. status , "running" ) ;
3306+
3307+ if ( ending === "cancelled" ) {
3308+ yield * orchestrator . cancelDelegatedTask ( {
3309+ parentThreadId,
3310+ taskId : followupTaskId ,
3311+ command : {
3312+ type : "thread.stop" ,
3313+ commandId : CommandId . make ( `command:followup-command-cancel-${ ending } ` ) ,
3314+ threadId : child . childThreadId ,
3315+ stopDelegatedTasks : false ,
3316+ } ,
3317+ stopDescendants : ( ) => Effect . void ,
3318+ } ) ;
3319+ yield * eventSink . write ( {
3320+ commandId : reconcileCommandId ( `followup-command-stopped-${ ending } ` ) ,
3321+ events : [
3322+ {
3323+ id : EventId . make ( `event:followup-command-stopped-${ ending } ` ) ,
3324+ type : "provider-thread.updated" ,
3325+ threadId : child . childThreadId ,
3326+ driver,
3327+ providerInstanceId : modelSelection . instanceId ,
3328+ occurredAt : now ,
3329+ payload : {
3330+ ...parent . providerThreads [ 0 ] ! ,
3331+ id : ProviderThreadId . make ( `provider-thread:followup-command-child-${ ending } ` ) ,
3332+ appThreadId : child . childThreadId ,
3333+ pendingBackgroundTasks : [ ] ,
3334+ } ,
3335+ } ,
3336+ ] ,
3337+ } ) ;
3338+ yield * orchestrator . recoverDelegatedTasks ;
3339+ const stopped = ( yield * orchestrator . getThreadProjection (
3340+ parentThreadId ,
3341+ ) ) . subagents . find ( ( task ) => task . id === followupTaskId ) ;
3342+ assert . equal ( stopped ?. cancelledRunId , followupRun . id ) ;
3343+ assert . equal ( stopped ?. status , "interrupted" ) ;
3344+ return ;
3345+ }
3346+
3347+ // The finished command wakes the child; that turn continues the same follow-up.
3348+ const wakeMessageId = MessageId . make ( `message:followup-command-wake-${ ending } ` ) ;
3349+ yield * orchestrator . dispatch ( {
3350+ type : "message.dispatch" ,
3351+ createdBy : "agent" ,
3352+ creationSource : "server" ,
3353+ commandId : CommandId . make ( `command:followup-command-wake-${ ending } ` ) ,
3354+ threadId : child . childThreadId ,
3355+ messageId : wakeMessageId ,
3356+ text : "Background activity updated" ,
3357+ notification : {
3358+ source : { kind : "command" } ,
3359+ outcome : "completed" ,
3360+ summary : 'Command "sleep 90" finished' ,
3361+ } ,
3362+ attachments : [ ] ,
3363+ dispatchMode : { type : "queue_after_active" } ,
3364+ } ) ;
3365+ const wakeRun = ( yield * orchestrator . getThreadProjection ( child . childThreadId ) ) . runs . find (
3366+ ( run ) => run . userMessageId === wakeMessageId ,
3367+ ) ! ;
3368+ assert . equal ( wakeRun . delegatedTaskId , followupTaskId ) ;
3369+ yield * eventSink . write ( {
3370+ commandId : reconcileCommandId ( `followup-command-wake-ended-${ ending } ` ) ,
3371+ events : [
3372+ {
3373+ id : EventId . make ( `event:followup-command-settled-${ ending } ` ) ,
3374+ type : "provider-thread.updated" ,
3375+ threadId : child . childThreadId ,
3376+ driver,
3377+ providerInstanceId : modelSelection . instanceId ,
3378+ occurredAt : now ,
3379+ payload : {
3380+ ...parent . providerThreads [ 0 ] ! ,
3381+ id : ProviderThreadId . make ( `provider-thread:followup-command-child-${ ending } ` ) ,
3382+ appThreadId : child . childThreadId ,
3383+ pendingBackgroundTasks : [ ] ,
3384+ } ,
3385+ } ,
3386+ {
3387+ id : EventId . make ( `event:followup-command-final-${ ending } ` ) ,
3388+ type : "message.updated" ,
3389+ threadId : child . childThreadId ,
3390+ runId : wakeRun . id ,
3391+ occurredAt : now ,
3392+ payload : {
3393+ id : MessageId . make ( `message:followup-command-final-${ ending } ` ) ,
3394+ threadId : child . childThreadId ,
3395+ runId : wakeRun . id ,
3396+ nodeId : null ,
3397+ role : "assistant" ,
3398+ text : "FINAL CHECK-OK" ,
3399+ attachments : [ ] ,
3400+ streaming : false ,
3401+ createdBy : "agent" ,
3402+ creationSource : "server" ,
3403+ createdAt : now ,
3404+ updatedAt : now ,
3405+ } ,
3406+ } ,
3407+ {
3408+ id : EventId . make ( `event:followup-command-wake-completed-${ ending } ` ) ,
3409+ type : "run.updated" ,
3410+ threadId : child . childThreadId ,
3411+ runId : wakeRun . id ,
3412+ occurredAt : now ,
3413+ payload : { ...wakeRun , status : "completed" , startedAt : now , completedAt : now } ,
3414+ } ,
3415+ ] ,
3416+ } ) ;
3417+ yield * orchestrator . recoverDelegatedTasks ;
3418+
3419+ const finished = yield * orchestrator . getThreadProjection ( parentThreadId ) ;
3420+ const childTasks = finished . subagents . filter (
3421+ ( task ) => task . childThreadId === child . childThreadId ,
3422+ ) ;
3423+ assert . deepEqual (
3424+ childTasks . map ( ( task ) => task . id ) . toSorted ( ) ,
3425+ [ child . taskId , followupTaskId ] . toSorted ( ) ,
3426+ ) ;
3427+ const followup = childTasks . find ( ( task ) => task . id === followupTaskId ) ;
3428+ assert . equal ( followup ?. status , "completed" ) ;
3429+ assert . equal ( followup ?. result , "FINAL CHECK-OK" ) ;
3430+ } ) ,
3431+ ) ;
3432+
31933433 it . effect ( "waits for a run the follow-up's child queued on its own thread" , ( ) =>
31943434 Effect . gen ( function * ( ) {
31953435 const requests = yield * ProviderContinuationRequests . ProviderContinuationRequests ;
0 commit comments