@@ -617,4 +617,165 @@ suite("Roo Code Subtasks", function () {
617617 await waitFor ( ( ) => api . getCurrentTaskStack ( ) . length === 0 ) . catch ( ( ) => { } )
618618 }
619619 } )
620+
621+ // Issue #559: explicit "Abandon subtask" action. Unlike cancellation alone (which leaves
622+ // the child "interrupted" and the parent "delegated" so the child can still resume and
623+ // report back), abandoning severs the link outright: the parent goes back to "active" and
624+ // the child's parentTaskId/rootTaskId are cleared so a later resume can never reattach it.
625+ test ( "abandoning an interrupted subtask severs the parent-child link" , async ( ) => {
626+ const api = globalThis . api
627+ const asks : Record < string , ClineMessage [ ] > = { }
628+ const says : Record < string , ClineMessage [ ] > = { }
629+
630+ const messageHandler = ( { taskId, message } : { taskId : string ; message : ClineMessage } ) => {
631+ if ( message . type === "ask" ) {
632+ asks [ taskId ] = asks [ taskId ] || [ ]
633+ asks [ taskId ] . push ( message )
634+ }
635+ if ( message . type === "say" && message . partial === false ) {
636+ says [ taskId ] = says [ taskId ] || [ ]
637+ says [ taskId ] . push ( message )
638+ }
639+ }
640+
641+ api . on ( RooCodeEventName . Message , messageHandler )
642+
643+ try {
644+ const parentTaskId = await api . startNewTask ( {
645+ configuration : {
646+ mode : "ask" ,
647+ alwaysAllowModeSwitch : true ,
648+ alwaysAllowSubtasks : true ,
649+ autoApprovalEnabled : true ,
650+ enableCheckpoints : false ,
651+ } ,
652+ text : SUBTASK_INTERRUPT_PARENT_PROMPT ,
653+ } )
654+
655+ let childTaskId : string | undefined
656+ await waitFor ( ( ) => {
657+ const stack = api . getCurrentTaskStack ( )
658+ const current = stack [ stack . length - 1 ]
659+ if ( current && current !== parentTaskId ) {
660+ childTaskId = current
661+ return true
662+ }
663+ return false
664+ } )
665+
666+ await waitFor ( ( ) => asks [ childTaskId ! ] ?. some ( ( { ask } ) => ask === "followup" ) ?? false )
667+ await waitFor ( async ( ) => ( await api . getTaskApiConversationHistoryLength ( childTaskId ! ) ) > 0 )
668+
669+ // Cancel the child — marked "interrupted", parent stays "delegated".
670+ await api . cancelCurrentTask ( )
671+
672+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . at ( - 1 ) === childTaskId )
673+ await waitFor (
674+ ( ) => asks [ childTaskId ! ] ?. some ( ( { type, ask } ) => type === "ask" && ask === "resume_task" ) ?? false ,
675+ )
676+
677+ const interruptedChild = await api . getTaskHistoryItem ( childTaskId ! )
678+ assert . strictEqual ( interruptedChild ?. status , "interrupted" , "Child should be marked interrupted" )
679+
680+ const delegatedParent = await api . getTaskHistoryItem ( parentTaskId )
681+ assert . strictEqual ( delegatedParent ?. status , "delegated" , "Parent should still be delegated before abandon" )
682+ assert . strictEqual (
683+ delegatedParent ?. awaitingChildId ,
684+ childTaskId ,
685+ "Parent should await the interrupted child" ,
686+ )
687+
688+ // The interrupted child is the live/open task at this point (cancelTask rehydrates
689+ // it onto the stack). Abandon must close that live instance before severing the
690+ // persisted link — otherwise a later save on the still-open child would rebuild
691+ // parentTaskId/rootTaskId from its live (readonly) fields and silently reattach it.
692+ const abandoned = await api . abandonSubtask ( childTaskId ! )
693+ assert . strictEqual ( abandoned , true , "abandonSubtask should report the link was severed" )
694+
695+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . at ( - 1 ) !== childTaskId )
696+
697+ const parentAfterAbandon = await api . getTaskHistoryItem ( parentTaskId )
698+ assert . strictEqual ( parentAfterAbandon ?. status , "active" , "Parent should return to active after abandon" )
699+ assert . strictEqual (
700+ parentAfterAbandon ?. awaitingChildId ,
701+ undefined ,
702+ "Parent awaitingChildId should be cleared" ,
703+ )
704+ assert . strictEqual ( parentAfterAbandon ?. delegatedToId , undefined , "Parent delegatedToId should be cleared" )
705+
706+ const childAfterAbandon = await api . getTaskHistoryItem ( childTaskId ! )
707+ // The child's own status is left untouched (VALID_TRANSITIONS only allows interrupted → completed);
708+ // only its parent/root links are cleared so it can never reattach to the parent again.
709+ assert . strictEqual ( childAfterAbandon ?. status , "interrupted" , "Child status stays interrupted" )
710+ assert . strictEqual ( childAfterAbandon ?. parentTaskId , undefined , "Child parentTaskId should be cleared" )
711+ assert . strictEqual ( childAfterAbandon ?. rootTaskId , undefined , "Child rootTaskId should be cleared" )
712+
713+ // A second abandon call is a no-op since the parent is no longer delegated to this child.
714+ const secondAbandon = await api . abandonSubtask ( childTaskId ! )
715+ assert . strictEqual ( secondAbandon , false , "Second abandonSubtask call should be a no-op" )
716+
717+ // Resume and complete the abandoned child — it must NOT reopen or reattach to the
718+ // parent. Before the abandon fix, a subsequent save on the still-live child could
719+ // silently rewrite its persisted parentTaskId back to the parent; this proves the
720+ // link stays severed all the way through a real resume/save/complete cycle.
721+ // api.resumeTask() re-instantiates the child from history, which re-raises its own
722+ // "resume_task" ask; answering it with the follow-up answer (same pattern the sibling
723+ // "cancelled child completes and reopens parent" test above uses) both resumes the
724+ // task and supplies the answer the re-asked follow-up question is waiting for.
725+ // asks[childTaskId] already holds the earlier resume_task ask from the pre-abandon
726+ // cancellation, so the wait below must look for a NEW one, not just any occurrence.
727+ const askCountBeforeResume = asks [ childTaskId ! ] ?. length ?? 0
728+ await api . resumeTask ( childTaskId ! )
729+ await waitFor ( ( ) =>
730+ ( asks [ childTaskId ! ] ?? [ ] )
731+ . slice ( askCountBeforeResume )
732+ . some ( ( { type, ask } ) => type === "ask" && ask === "resume_task" ) ,
733+ )
734+
735+ const completedChildTaskId = await waitUntilCompleted ( {
736+ api,
737+ start : async ( ) => {
738+ await api . sendMessage ( SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER )
739+ return childTaskId !
740+ } ,
741+ } )
742+
743+ assert . strictEqual (
744+ completedChildTaskId ,
745+ childTaskId ,
746+ "The abandoned child itself should be the task that completes, not the parent" ,
747+ )
748+ assert . strictEqual (
749+ says [ parentTaskId ] ?. find ( ( { say } ) => say === "completion_result" ) ,
750+ undefined ,
751+ "Parent must never complete/reopen after its abandoned child resumes and completes" ,
752+ )
753+
754+ const parentAfterChildCompletes = await api . getTaskHistoryItem ( parentTaskId )
755+ assert . strictEqual (
756+ parentAfterChildCompletes ?. status ,
757+ "active" ,
758+ "Parent status must remain untouched by the abandoned child's completion" ,
759+ )
760+ assert . strictEqual (
761+ parentAfterChildCompletes ?. awaitingChildId ,
762+ undefined ,
763+ "Parent must not start awaiting the abandoned child again" ,
764+ )
765+
766+ const childAfterCompletion = await api . getTaskHistoryItem ( childTaskId ! )
767+ assert . strictEqual (
768+ childAfterCompletion ?. parentTaskId ,
769+ undefined ,
770+ "Child parentTaskId must still be cleared after it completes on its own — " +
771+ "proves the live-instance save did not resurrect the old link" ,
772+ )
773+ } finally {
774+ api . off ( RooCodeEventName . Message , messageHandler )
775+ while ( api . getCurrentTaskStack ( ) . length > 0 ) {
776+ await api . clearCurrentTask ( )
777+ }
778+ await waitFor ( ( ) => api . getCurrentTaskStack ( ) . length === 0 ) . catch ( ( ) => { } )
779+ }
780+ } )
620781} )
0 commit comments