@@ -9,25 +9,84 @@ 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+ } finally {
74+ api . off ( RooCodeEventName . Message , messageHandler )
75+ }
76+ } )
77+
78+ // Race mitigation: runDelegationTransition lock + cancelledDelegationChildIds guard
79+ // ensures cancelTask() wins over a concurrent reopenParentFromDelegation() (Race 3).
80+ test ( "cancelled child completes in-place and does not reopen parent" , async ( ) => {
81+ const api = globalThis . api
82+ const asks : Record < string , ClineMessage [ ] > = { }
83+ const messages : Record < string , ClineMessage [ ] > = { }
84+
2585 const messageHandler = ( { taskId, message } : { taskId : string ; message : ClineMessage } ) => {
2686 if ( message . type === "ask" ) {
2787 asks [ taskId ] = asks [ taskId ] || [ ]
2888 asks [ taskId ] . push ( message )
2989 }
30-
3190 if ( message . type === "say" && message . partial === false ) {
3291 messages [ taskId ] = messages [ taskId ] || [ ]
3392 messages [ taskId ] . push ( message )
@@ -64,35 +123,25 @@ suite("Roo Code Subtasks", function () {
64123 } )
65124
66125 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
126+ await waitFor ( ( ) => {
127+ const stack = api . getCurrentTaskStack ( )
128+ const current = stack [ stack . length - 1 ]
129+ if ( current && current !== parentTaskId ) {
130+ spawnedTaskId = current
72131 return true
73132 }
74133 return false
75134 } )
76- await waitForStage (
77- "wait for delegated child followup ask" ,
135+
136+ await waitFor (
78137 ( ) => asks [ spawnedTaskId ! ] ?. some ( ( { type, ask } ) => type === "ask" && ask === "followup" ) ?? false ,
79138 )
80- const cancelledChildTaskId = spawnedTaskId !
81139
140+ const cancelledChildTaskId = spawnedTaskId !
82141 await api . cancelCurrentTask ( )
83142
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" ,
143+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . at ( - 1 ) === cancelledChildTaskId )
144+ await waitFor (
96145 ( ) =>
97146 asks [ cancelledChildTaskId ] ?. some ( ( { type, ask } ) => type === "ask" && ask === "resume_task" ) ??
98147 false ,
@@ -126,7 +175,6 @@ suite("Roo Code Subtasks", function () {
126175 cancelledChildTaskId ,
127176 "Cancelled child task should remain the active completed task" ,
128177 )
129-
130178 assert . ok (
131179 messages [ parentTaskId ] ?. find ( ( { type, text } ) => type === "say" && text === "Parent task resumed" ) ===
132180 undefined ,
0 commit comments