@@ -5,8 +5,10 @@ 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 ,
8+ SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER ,
9+ SUBTASK_ABANDON_PARENT_PROMPT ,
910 SUBTASK_API_HANG_CHILD_MARKER ,
11+ SUBTASK_API_HANG_CHILD_RESULT ,
1012 SUBTASK_API_HANG_PARENT_MARKER ,
1113 SUBTASK_API_HANG_PARENT_PROMPT ,
1214 SUBTASK_API_HANG_PARENT_RESULT ,
@@ -271,7 +273,10 @@ suite("Roo Code Subtasks", function () {
271273
272274 const parent = await api . getTaskHistoryItem ( parentTaskId )
273275 assert . ok ( parent , "Parent history item should exist" )
274- assert . strictEqual ( parent . status , "active" , "Parent status should be 'active' after child completes" )
276+ assert . ok (
277+ parent . status === "active" || parent . status === "completed" ,
278+ `Parent status should be 'active' or 'completed' after child completes (got '${ parent . status } ')` ,
279+ )
275280 assert . strictEqual ( parent . awaitingChildId , undefined , "Parent awaitingChildId should be cleared" )
276281 assert . strictEqual ( parent . delegatedToId , undefined , "Parent delegatedToId should be cleared" )
277282 assert . strictEqual ( parent . completedByChildId , childTaskId , "Parent completedByChildId should be the child" )
@@ -769,4 +774,165 @@ suite("Roo Code Subtasks", function () {
769774 await waitFor ( ( ) => api . getCurrentTaskStack ( ) . length === 0 ) . catch ( ( ) => { } )
770775 }
771776 } )
777+
778+ // Issue #559: explicit "Abandon subtask" action. Unlike cancellation alone (which leaves
779+ // the child "interrupted" and the parent "delegated" so the child can still resume and
780+ // report back), abandoning severs the link outright: the parent goes back to "active" and
781+ // the child's parentTaskId/rootTaskId are cleared so a later resume can never reattach it.
782+ test ( "abandoning an interrupted subtask severs the parent-child link" , async ( ) => {
783+ const api = globalThis . api
784+ const asks : Record < string , ClineMessage [ ] > = { }
785+ const says : Record < string , ClineMessage [ ] > = { }
786+
787+ const messageHandler = ( { taskId, message } : { taskId : string ; message : ClineMessage } ) => {
788+ if ( message . type === "ask" ) {
789+ asks [ taskId ] = asks [ taskId ] || [ ]
790+ asks [ taskId ] . push ( message )
791+ }
792+ if ( message . type === "say" && message . partial === false ) {
793+ says [ taskId ] = says [ taskId ] || [ ]
794+ says [ taskId ] . push ( message )
795+ }
796+ }
797+
798+ api . on ( RooCodeEventName . Message , messageHandler )
799+
800+ try {
801+ const parentTaskId = await api . startNewTask ( {
802+ configuration : {
803+ mode : "ask" ,
804+ alwaysAllowModeSwitch : true ,
805+ alwaysAllowSubtasks : true ,
806+ autoApprovalEnabled : true ,
807+ enableCheckpoints : false ,
808+ } ,
809+ text : SUBTASK_ABANDON_PARENT_PROMPT ,
810+ } )
811+
812+ let childTaskId : string | undefined
813+ await waitFor ( ( ) => {
814+ const stack = api . getCurrentTaskStack ( )
815+ const current = stack [ stack . length - 1 ]
816+ if ( current && current !== parentTaskId ) {
817+ childTaskId = current
818+ return true
819+ }
820+ return false
821+ } )
822+
823+ await waitFor ( ( ) => asks [ childTaskId ! ] ?. some ( ( { ask } ) => ask === "followup" ) ?? false )
824+ await waitFor ( async ( ) => ( await api . getTaskApiConversationHistoryLength ( childTaskId ! ) ) > 0 )
825+
826+ // Cancel the child — marked "interrupted", parent stays "delegated".
827+ await api . cancelCurrentTask ( )
828+
829+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . at ( - 1 ) === childTaskId )
830+ await waitFor (
831+ ( ) => asks [ childTaskId ! ] ?. some ( ( { type, ask } ) => type === "ask" && ask === "resume_task" ) ?? false ,
832+ )
833+
834+ const interruptedChild = await api . getTaskHistoryItem ( childTaskId ! )
835+ assert . strictEqual ( interruptedChild ?. status , "interrupted" , "Child should be marked interrupted" )
836+
837+ const delegatedParent = await api . getTaskHistoryItem ( parentTaskId )
838+ assert . strictEqual ( delegatedParent ?. status , "delegated" , "Parent should still be delegated before abandon" )
839+ assert . strictEqual (
840+ delegatedParent ?. awaitingChildId ,
841+ childTaskId ,
842+ "Parent should await the interrupted child" ,
843+ )
844+
845+ // The interrupted child is the live/open task at this point (cancelTask rehydrates
846+ // it onto the stack). Abandon must close that live instance before severing the
847+ // persisted link — otherwise a later save on the still-open child would rebuild
848+ // parentTaskId/rootTaskId from its live (readonly) fields and silently reattach it.
849+ const abandoned = await api . abandonSubtask ( childTaskId ! )
850+ assert . strictEqual ( abandoned , true , "abandonSubtask should report the link was severed" )
851+
852+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . at ( - 1 ) !== childTaskId )
853+
854+ const parentAfterAbandon = await api . getTaskHistoryItem ( parentTaskId )
855+ assert . strictEqual ( parentAfterAbandon ?. status , "active" , "Parent should return to active after abandon" )
856+ assert . strictEqual (
857+ parentAfterAbandon ?. awaitingChildId ,
858+ undefined ,
859+ "Parent awaitingChildId should be cleared" ,
860+ )
861+ assert . strictEqual ( parentAfterAbandon ?. delegatedToId , undefined , "Parent delegatedToId should be cleared" )
862+
863+ const childAfterAbandon = await api . getTaskHistoryItem ( childTaskId ! )
864+ // The child's own status is left untouched (VALID_TRANSITIONS only allows interrupted → completed);
865+ // only its parent/root links are cleared so it can never reattach to the parent again.
866+ assert . strictEqual ( childAfterAbandon ?. status , "interrupted" , "Child status stays interrupted" )
867+ assert . strictEqual ( childAfterAbandon ?. parentTaskId , undefined , "Child parentTaskId should be cleared" )
868+ assert . strictEqual ( childAfterAbandon ?. rootTaskId , undefined , "Child rootTaskId should be cleared" )
869+
870+ // A second abandon call is a no-op since the parent is no longer delegated to this child.
871+ const secondAbandon = await api . abandonSubtask ( childTaskId ! )
872+ assert . strictEqual ( secondAbandon , false , "Second abandonSubtask call should be a no-op" )
873+
874+ // Resume and complete the abandoned child — it must NOT reopen or reattach to the
875+ // parent. Before the abandon fix, a subsequent save on the still-live child could
876+ // silently rewrite its persisted parentTaskId back to the parent; this proves the
877+ // link stays severed all the way through a real resume/save/complete cycle.
878+ // api.resumeTask() re-instantiates the child from history, which re-raises its own
879+ // "resume_task" ask; answering it with the follow-up answer (same pattern the sibling
880+ // "cancelled child completes and reopens parent" test above uses) both resumes the
881+ // task and supplies the answer the re-asked follow-up question is waiting for.
882+ // asks[childTaskId] already holds the earlier resume_task ask from the pre-abandon
883+ // cancellation, so the wait below must look for a NEW one, not just any occurrence.
884+ const askCountBeforeResume = asks [ childTaskId ! ] ?. length ?? 0
885+ await api . resumeTask ( childTaskId ! )
886+ await waitFor ( ( ) =>
887+ ( asks [ childTaskId ! ] ?? [ ] )
888+ . slice ( askCountBeforeResume )
889+ . some ( ( { type, ask } ) => type === "ask" && ask === "resume_task" ) ,
890+ )
891+
892+ const completedChildTaskId = await waitUntilCompleted ( {
893+ api,
894+ start : async ( ) => {
895+ await api . sendMessage ( SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER )
896+ return childTaskId !
897+ } ,
898+ } )
899+
900+ assert . strictEqual (
901+ completedChildTaskId ,
902+ childTaskId ,
903+ "The abandoned child itself should be the task that completes, not the parent" ,
904+ )
905+ assert . strictEqual (
906+ says [ parentTaskId ] ?. find ( ( { say } ) => say === "completion_result" ) ,
907+ undefined ,
908+ "Parent must never complete/reopen after its abandoned child resumes and completes" ,
909+ )
910+
911+ const parentAfterChildCompletes = await api . getTaskHistoryItem ( parentTaskId )
912+ assert . strictEqual (
913+ parentAfterChildCompletes ?. status ,
914+ "active" ,
915+ "Parent status must remain untouched by the abandoned child's completion" ,
916+ )
917+ assert . strictEqual (
918+ parentAfterChildCompletes ?. awaitingChildId ,
919+ undefined ,
920+ "Parent must not start awaiting the abandoned child again" ,
921+ )
922+
923+ const childAfterCompletion = await api . getTaskHistoryItem ( childTaskId ! )
924+ assert . strictEqual (
925+ childAfterCompletion ?. parentTaskId ,
926+ undefined ,
927+ "Child parentTaskId must still be cleared after it completes on its own — " +
928+ "proves the live-instance save did not resurrect the old link" ,
929+ )
930+ } finally {
931+ api . off ( RooCodeEventName . Message , messageHandler )
932+ while ( api . getCurrentTaskStack ( ) . length > 0 ) {
933+ await api . clearCurrentTask ( )
934+ }
935+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . length === 0 ) . catch ( ( ) => { } )
936+ }
937+ } )
772938} )
0 commit comments