@@ -5,6 +5,12 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
55import { setDefaultSuiteTimeout } from "./test-utils"
66import { sleep , waitFor , waitUntilCompleted } from "./utils"
77import {
8+ SUBTASK_API_HANG_CHILD_RESULT ,
9+ SUBTASK_API_HANG_CHILD_MARKER ,
10+ SUBTASK_API_HANG_PARENT_MARKER ,
11+ SUBTASK_API_HANG_PARENT_PROMPT ,
12+ SUBTASK_API_HANG_PARENT_RESULT ,
13+ SUBTASK_API_HANG_RESUME_MESSAGE ,
814 SUBTASK_CHILD_FOLLOWUP_ANSWER ,
915 SUBTASK_FAST_PARENT_PROMPT ,
1016 SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER ,
@@ -17,6 +23,45 @@ import {
1723 SUBTASK_XPROFILE_SAME_CHILD_RESULT ,
1824} from "../fixtures/subtasks"
1925
26+ type AimockMessageContent = string | Array < { type ?: string ; text ?: string } >
27+
28+ type AimockJournalEntry = {
29+ body ?: {
30+ messages ?: Array < {
31+ role ?: string
32+ content ?: AimockMessageContent
33+ } >
34+ }
35+ }
36+
37+ const messageContentText = ( content ?: AimockMessageContent ) => {
38+ if ( typeof content === "string" ) {
39+ return content
40+ }
41+
42+ return content ?. map ( ( part ) => part . text ?? "" ) . join ( "" ) ?? ""
43+ }
44+
45+ const waitForAimockRequestContaining = async ( expectedText : string , excludeText ?: string ) => {
46+ const aimockUrl = process . env . AIMOCK_URL
47+ assert . ok ( aimockUrl , "AIMOCK_URL must be set for aimock journal assertions" )
48+
49+ await waitFor ( async ( ) => {
50+ const response = await fetch ( `${ aimockUrl } /__aimock/journal` )
51+ const entries = ( await response . json ( ) ) as AimockJournalEntry [ ]
52+
53+ return entries . some ( ( entry ) => {
54+ const messages = entry . body ?. messages
55+ if ( ! messages ) return false
56+ const entryText = messages . map ( ( m ) => messageContentText ( m . content ) ) . join ( "" )
57+ if ( excludeText && entryText . includes ( excludeText ) ) return false
58+ return messages . some (
59+ ( message ) => message . role === "user" && messageContentText ( message . content ) . includes ( expectedText ) ,
60+ )
61+ } )
62+ } )
63+ }
64+
2065suite ( "Roo Code Subtasks" , function ( ) {
2166 setDefaultSuiteTimeout ( this )
2267
@@ -412,6 +457,113 @@ suite("Roo Code Subtasks", function () {
412457 }
413458 } )
414459
460+ // Issue #566: a child interrupted while its provider request is still pending
461+ // must keep its parent link when manually resumed and completed.
462+ test ( "API-hung interrupted child resumes and returns to parent" , async ( ) => {
463+ const api = globalThis . api
464+ const asks : Record < string , ClineMessage [ ] > = { }
465+ const says : Record < string , ClineMessage [ ] > = { }
466+
467+ const messageHandler = ( { taskId, message } : { taskId : string ; message : ClineMessage } ) => {
468+ if ( message . type === "ask" ) {
469+ asks [ taskId ] = asks [ taskId ] || [ ]
470+ asks [ taskId ] . push ( message )
471+ }
472+ if ( message . type === "say" && message . partial === false ) {
473+ says [ taskId ] = says [ taskId ] || [ ]
474+ says [ taskId ] . push ( message )
475+ }
476+ }
477+
478+ api . on ( RooCodeEventName . Message , messageHandler )
479+
480+ try {
481+ const parentTaskId = await api . startNewTask ( {
482+ configuration : {
483+ mode : "ask" ,
484+ alwaysAllowModeSwitch : true ,
485+ alwaysAllowSubtasks : true ,
486+ autoApprovalEnabled : true ,
487+ enableCheckpoints : false ,
488+ } ,
489+ text : SUBTASK_API_HANG_PARENT_PROMPT ,
490+ } )
491+
492+ let childTaskId : string | undefined
493+ await waitFor ( ( ) => {
494+ const stack = api . getCurrentTaskStack ( )
495+ const current = stack [ stack . length - 1 ]
496+ if ( current && current !== parentTaskId ) {
497+ childTaskId = current
498+ return true
499+ }
500+ return false
501+ } )
502+
503+ await waitForAimockRequestContaining ( SUBTASK_API_HANG_CHILD_MARKER , SUBTASK_API_HANG_PARENT_MARKER )
504+
505+ await api . cancelCurrentTask ( )
506+
507+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . at ( - 1 ) === childTaskId )
508+ await waitFor (
509+ ( ) => asks [ childTaskId ! ] ?. some ( ( { type, ask } ) => type === "ask" && ask === "resume_task" ) ?? false ,
510+ )
511+
512+ const interruptedChild = await api . getTaskHistoryItem ( childTaskId ! )
513+ assert . strictEqual ( interruptedChild ?. status , "interrupted" , "Child should be interrupted after manual stop" )
514+ assert . strictEqual (
515+ interruptedChild ?. parentTaskId ,
516+ parentTaskId ,
517+ "Interrupted child should retain its parent link before resume" ,
518+ )
519+
520+ const completedParentTaskId = await waitUntilCompleted ( {
521+ api,
522+ start : async ( ) => {
523+ await api . sendMessage ( SUBTASK_API_HANG_RESUME_MESSAGE )
524+ return parentTaskId
525+ } ,
526+ } )
527+
528+ assert . strictEqual (
529+ completedParentTaskId ,
530+ parentTaskId ,
531+ "Parent task should complete after API-hung child resumes and reports back" ,
532+ )
533+ assert . strictEqual (
534+ says [ childTaskId ! ]
535+ ?. filter ( ( { say } ) => say === "completion_result" )
536+ . map ( ( { text } ) => text ?. trim ( ) )
537+ . find ( ( text ) => text === SUBTASK_API_HANG_CHILD_RESULT ) ,
538+ SUBTASK_API_HANG_CHILD_RESULT ,
539+ "Child should complete with its expected result after resume" ,
540+ )
541+ assert . strictEqual (
542+ says [ parentTaskId ]
543+ ?. filter ( ( { say } ) => say === "completion_result" )
544+ . map ( ( { text } ) => text ?. trim ( ) )
545+ . find ( ( text ) => text === SUBTASK_API_HANG_PARENT_RESULT ) ,
546+ SUBTASK_API_HANG_PARENT_RESULT ,
547+ "Parent should resume and complete with its expected result" ,
548+ )
549+
550+ const parent = await api . getTaskHistoryItem ( parentTaskId )
551+ assert . notStrictEqual ( parent ?. status , "delegated" , "Parent history should not remain delegated" )
552+ assert . strictEqual ( parent ?. awaitingChildId , undefined , "Parent awaitingChildId should be cleared" )
553+ assert . strictEqual ( parent ?. completedByChildId , childTaskId , "Parent should record completed child" )
554+
555+ const child = await api . getTaskHistoryItem ( childTaskId ! )
556+ assert . strictEqual ( child ?. status , "completed" , "Child history should be completed" )
557+ assert . strictEqual ( child ?. parentTaskId , parentTaskId , "Completed child should still point to parent" )
558+ } finally {
559+ api . off ( RooCodeEventName . Message , messageHandler )
560+ while ( api . getCurrentTaskStack ( ) . length > 0 ) {
561+ await api . clearCurrentTask ( )
562+ }
563+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . length === 0 ) . catch ( ( ) => { } )
564+ }
565+ } )
566+
415567 test ( "same-profile child returns before a different-profile child" , async ( ) => {
416568 const api = globalThis . api
417569 const says : Record < string , ClineMessage [ ] > = { }
0 commit comments