@@ -9,25 +9,87 @@ import { SUBTASK_CHILD_FOLLOWUP_ANSWER, SUBTASK_PARENT_PROMPT } from "../fixture
99suite ( "Roo Code Subtasks" , function ( ) {
1010 setDefaultSuiteTimeout ( this )
1111
12- test ( "Should keep parent paused after subtask cancellation" , async ( ) => {
12+ // Race mitigation: skipDelegationRepair prevents removeClineFromStack from
13+ // auto-resuming the parent when the child is cancelled (Race 2).
14+ test ( "parent stays paused after subtask cancellation" , async ( ) => {
1315 const api = globalThis . api
1416 const asks : Record < string , ClineMessage [ ] > = { }
1517 const messages : Record < string , ClineMessage [ ] > = { }
16- const waitForStage = async ( label : string , condition : Parameters < typeof waitFor > [ 0 ] ) => {
17- try {
18- await waitFor ( condition )
19- } catch ( error ) {
20- const message = error instanceof Error ? error . message : String ( error )
21- throw new Error ( `${ label } : ${ message } ` )
18+
19+ const messageHandler = ( { taskId, message } : { taskId : string ; message : ClineMessage } ) => {
20+ if ( message . type === "ask" ) {
21+ asks [ taskId ] = asks [ taskId ] || [ ]
22+ asks [ taskId ] . push ( message )
23+ }
24+ if ( message . type === "say" && message . partial === false ) {
25+ messages [ taskId ] = messages [ taskId ] || [ ]
26+ messages [ taskId ] . push ( message )
2227 }
2328 }
2429
30+ api . on ( RooCodeEventName . Message , messageHandler )
31+
32+ try {
33+ const parentTaskId = await api . startNewTask ( {
34+ configuration : {
35+ mode : "ask" ,
36+ alwaysAllowModeSwitch : true ,
37+ alwaysAllowSubtasks : true ,
38+ autoApprovalEnabled : true ,
39+ enableCheckpoints : false ,
40+ } ,
41+ text : SUBTASK_PARENT_PROMPT ,
42+ } )
43+
44+ let spawnedTaskId : string | undefined
45+ await waitFor ( ( ) => {
46+ const stack = api . getCurrentTaskStack ( )
47+ const current = stack [ stack . length - 1 ]
48+ if ( current && current !== parentTaskId ) {
49+ spawnedTaskId = current
50+ return true
51+ }
52+ return false
53+ } )
54+
55+ await waitFor (
56+ ( ) => asks [ spawnedTaskId ! ] ?. some ( ( { type, ask } ) => type === "ask" && ask === "followup" ) ?? false ,
57+ )
58+
59+ await api . cancelCurrentTask ( )
60+
61+ assert . ok (
62+ messages [ parentTaskId ] ?. find ( ( { type, text } ) => type === "say" && text === "Parent task resumed" ) ===
63+ undefined ,
64+ "Parent task should not have resumed after subtask cancellation" ,
65+ )
66+
67+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . at ( - 1 ) === spawnedTaskId )
68+ await waitFor (
69+ ( ) => asks [ spawnedTaskId ! ] ?. some ( ( { type, ask } ) => type === "ask" && ask === "resume_task" ) ?? false ,
70+ )
71+
72+ await api . clearCurrentTask ( )
73+ // The parent task is still in the stack; drain it so it doesn't leak into the next test.
74+ await api . clearCurrentTask ( )
75+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . length === 0 )
76+ } finally {
77+ api . off ( RooCodeEventName . Message , messageHandler )
78+ }
79+ } )
80+
81+ // Race mitigation: runDelegationTransition lock + cancelledDelegationChildIds guard
82+ // ensures cancelTask() wins over a concurrent reopenParentFromDelegation() (Race 3).
83+ test ( "cancelled child completes in-place and does not reopen parent" , async ( ) => {
84+ const api = globalThis . api
85+ const asks : Record < string , ClineMessage [ ] > = { }
86+ const messages : Record < string , ClineMessage [ ] > = { }
87+
2588 const messageHandler = ( { taskId, message } : { taskId : string ; message : ClineMessage } ) => {
2689 if ( message . type === "ask" ) {
2790 asks [ taskId ] = asks [ taskId ] || [ ]
2891 asks [ taskId ] . push ( message )
2992 }
30-
3193 if ( message . type === "say" && message . partial === false ) {
3294 messages [ taskId ] = messages [ taskId ] || [ ]
3395 messages [ taskId ] . push ( message )
@@ -64,35 +126,25 @@ suite("Roo Code Subtasks", function () {
64126 } )
65127
66128 let spawnedTaskId : string | undefined
67- await waitForStage ( "wait for spawned subtask" , ( ) => {
68- const currentTaskStack = api . getCurrentTaskStack ( )
69- const currentTaskId = currentTaskStack [ currentTaskStack . length - 1 ]
70- if ( currentTaskId && currentTaskId !== parentTaskId ) {
71- spawnedTaskId = currentTaskId
129+ await waitFor ( ( ) => {
130+ const stack = api . getCurrentTaskStack ( )
131+ const current = stack [ stack . length - 1 ]
132+ if ( current && current !== parentTaskId ) {
133+ spawnedTaskId = current
72134 return true
73135 }
74136 return false
75137 } )
76- await waitForStage (
77- "wait for delegated child followup ask" ,
138+
139+ await waitFor (
78140 ( ) => asks [ spawnedTaskId ! ] ?. some ( ( { type, ask } ) => type === "ask" && ask === "followup" ) ?? false ,
79141 )
80- const cancelledChildTaskId = spawnedTaskId !
81142
143+ const cancelledChildTaskId = spawnedTaskId !
82144 await api . cancelCurrentTask ( )
83145
84- assert . ok (
85- messages [ parentTaskId ] ?. find ( ( { type, text } ) => type === "say" && text === "Parent task resumed" ) ===
86- undefined ,
87- "Parent task should not have resumed after subtask cancellation" ,
88- )
89-
90- await waitForStage (
91- "wait for cancelled child task to remain active" ,
92- ( ) => api . getCurrentTaskStack ( ) . at ( - 1 ) === cancelledChildTaskId ,
93- )
94- await waitForStage (
95- "wait for cancelled child resume ask" ,
146+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . at ( - 1 ) === cancelledChildTaskId )
147+ await waitFor (
96148 ( ) =>
97149 asks [ cancelledChildTaskId ] ?. some ( ( { type, ask } ) => type === "ask" && ask === "resume_task" ) ??
98150 false ,
@@ -126,7 +178,6 @@ suite("Roo Code Subtasks", function () {
126178 cancelledChildTaskId ,
127179 "Cancelled child task should remain the active completed task" ,
128180 )
129-
130181 assert . ok (
131182 messages [ parentTaskId ] ?. find ( ( { type, text } ) => type === "say" && text === "Parent task resumed" ) ===
132183 undefined ,
0 commit comments