@@ -541,4 +541,167 @@ describe("History resume delegation - parent metadata transitions", () => {
541541 } ) ,
542542 )
543543 } )
544+
545+ it ( "reopenParentFromDelegation skips duplicate tool_result when one already exists in history (EXT-665)" , async ( ) => {
546+ const logSpy = vi . fn ( )
547+ const provider = {
548+ contextProxy : { globalStorageUri : { fsPath : "/storage" } } ,
549+ log : logSpy ,
550+ getTaskWithId : vi . fn ( ) . mockResolvedValue ( {
551+ historyItem : {
552+ id : "p-dup" ,
553+ status : "delegated" ,
554+ awaitingChildId : "c-dup" ,
555+ childIds : [ ] ,
556+ ts : 100 ,
557+ task : "Parent with existing tool_result" ,
558+ tokensIn : 0 ,
559+ tokensOut : 0 ,
560+ totalCost : 0 ,
561+ } ,
562+ } ) ,
563+ emit : vi . fn ( ) ,
564+ getCurrentTask : vi . fn ( ( ) => ( { taskId : "c-dup" } ) ) ,
565+ removeClineFromStack : vi . fn ( ) . mockResolvedValue ( undefined ) ,
566+ createTaskWithHistoryItem : vi . fn ( ) . mockResolvedValue ( {
567+ taskId : "p-dup" ,
568+ resumeAfterDelegation : vi . fn ( ) . mockResolvedValue ( undefined ) ,
569+ overwriteClineMessages : vi . fn ( ) . mockResolvedValue ( undefined ) ,
570+ overwriteApiConversationHistory : vi . fn ( ) . mockResolvedValue ( undefined ) ,
571+ } ) ,
572+ updateTaskHistory : vi . fn ( ) . mockResolvedValue ( [ ] ) ,
573+ } as unknown as ClineProvider
574+
575+ // Simulate the bug scenario: API history already has a tool_result for the same tool_use_id
576+ // This can happen if the tool was interrupted and the result was already added
577+ const existingToolUseId = "toolu_01SnH3c7xgVdfLc2md4Fk6yB"
578+ const existingUiMessages = [ { type : "ask" , ask : "tool" , text : "new_task request" , ts : 50 } ]
579+ const existingApiMessages = [
580+ { role : "user" , content : [ { type : "text" , text : "Create a subtask" } ] , ts : 40 } ,
581+ {
582+ role : "assistant" ,
583+ content : [
584+ {
585+ type : "tool_use" ,
586+ name : "new_task" ,
587+ id : existingToolUseId ,
588+ input : { mode : "code" , message : "Do something" } ,
589+ } ,
590+ ] ,
591+ ts : 50 ,
592+ } ,
593+ // This tool_result already exists from a previous operation (e.g., interruption)
594+ {
595+ role : "user" ,
596+ content : [
597+ {
598+ type : "tool_result" ,
599+ tool_use_id : existingToolUseId ,
600+ content : "Tool execution was interrupted before completion." ,
601+ } ,
602+ ] ,
603+ ts : 60 ,
604+ } ,
605+ ]
606+
607+ vi . mocked ( readTaskMessages ) . mockResolvedValue ( existingUiMessages as any )
608+ vi . mocked ( readApiMessages ) . mockResolvedValue ( existingApiMessages as any )
609+
610+ await ( ClineProvider . prototype as any ) . reopenParentFromDelegation . call ( provider , {
611+ parentTaskId : "p-dup" ,
612+ childTaskId : "c-dup" ,
613+ completionResultSummary : "Subtask completed successfully" ,
614+ } )
615+
616+ // Verify that we logged the skip message
617+ expect ( logSpy ) . toHaveBeenCalledWith (
618+ expect . stringContaining ( `Skipping duplicate tool_result for tool_use_id: ${ existingToolUseId } ` ) ,
619+ )
620+
621+ // Verify API history was saved WITHOUT a new tool_result (should still have exactly 3 messages)
622+ const apiCall = vi . mocked ( saveApiMessages ) . mock . calls [ 0 ] [ 0 ]
623+ expect ( apiCall . messages ) . toHaveLength ( 3 ) // Original 3 messages, no new one added
624+
625+ // Count tool_result blocks - should only be 1 (the existing one)
626+ const toolResultBlocks = apiCall . messages . flatMap ( ( msg : any ) =>
627+ msg . role === "user" && Array . isArray ( msg . content )
628+ ? msg . content . filter ( ( block : any ) => block . type === "tool_result" )
629+ : [ ] ,
630+ )
631+ expect ( toolResultBlocks ) . toHaveLength ( 1 )
632+ expect ( toolResultBlocks [ 0 ] . tool_use_id ) . toBe ( existingToolUseId )
633+ } )
634+
635+ it ( "reopenParentFromDelegation adds tool_result when none exists for the tool_use_id (normal case)" , async ( ) => {
636+ const logSpy = vi . fn ( )
637+ const provider = {
638+ contextProxy : { globalStorageUri : { fsPath : "/storage" } } ,
639+ log : logSpy ,
640+ getTaskWithId : vi . fn ( ) . mockResolvedValue ( {
641+ historyItem : {
642+ id : "p-new" ,
643+ status : "delegated" ,
644+ awaitingChildId : "c-new" ,
645+ childIds : [ ] ,
646+ ts : 100 ,
647+ task : "Parent without tool_result yet" ,
648+ tokensIn : 0 ,
649+ tokensOut : 0 ,
650+ totalCost : 0 ,
651+ } ,
652+ } ) ,
653+ emit : vi . fn ( ) ,
654+ getCurrentTask : vi . fn ( ( ) => ( { taskId : "c-new" } ) ) ,
655+ removeClineFromStack : vi . fn ( ) . mockResolvedValue ( undefined ) ,
656+ createTaskWithHistoryItem : vi . fn ( ) . mockResolvedValue ( {
657+ taskId : "p-new" ,
658+ resumeAfterDelegation : vi . fn ( ) . mockResolvedValue ( undefined ) ,
659+ overwriteClineMessages : vi . fn ( ) . mockResolvedValue ( undefined ) ,
660+ overwriteApiConversationHistory : vi . fn ( ) . mockResolvedValue ( undefined ) ,
661+ } ) ,
662+ updateTaskHistory : vi . fn ( ) . mockResolvedValue ( [ ] ) ,
663+ } as unknown as ClineProvider
664+
665+ // Normal case: API history has tool_use but no tool_result yet
666+ const toolUseId = "toolu_normal123"
667+ const existingUiMessages = [ { type : "ask" , ask : "tool" , text : "new_task request" , ts : 50 } ]
668+ const existingApiMessages = [
669+ { role : "user" , content : [ { type : "text" , text : "Create a subtask" } ] , ts : 40 } ,
670+ {
671+ role : "assistant" ,
672+ content : [
673+ {
674+ type : "tool_use" ,
675+ name : "new_task" ,
676+ id : toolUseId ,
677+ input : { mode : "code" , message : "Do something" } ,
678+ } ,
679+ ] ,
680+ ts : 50 ,
681+ } ,
682+ ]
683+
684+ vi . mocked ( readTaskMessages ) . mockResolvedValue ( existingUiMessages as any )
685+ vi . mocked ( readApiMessages ) . mockResolvedValue ( existingApiMessages as any )
686+
687+ await ( ClineProvider . prototype as any ) . reopenParentFromDelegation . call ( provider , {
688+ parentTaskId : "p-new" ,
689+ childTaskId : "c-new" ,
690+ completionResultSummary : "Subtask completed successfully" ,
691+ } )
692+
693+ // Verify that we did NOT log a skip message
694+ expect ( logSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( "Skipping duplicate tool_result" ) )
695+
696+ // Verify API history was saved WITH a new tool_result (should have 3 messages now)
697+ const apiCall = vi . mocked ( saveApiMessages ) . mock . calls [ 0 ] [ 0 ]
698+ expect ( apiCall . messages ) . toHaveLength ( 3 )
699+
700+ // The last message should be the new tool_result
701+ const lastMsg = apiCall . messages [ 2 ]
702+ expect ( lastMsg . role ) . toBe ( "user" )
703+ expect ( ( lastMsg . content [ 0 ] as any ) . type ) . toBe ( "tool_result" )
704+ expect ( ( lastMsg . content [ 0 ] as any ) . tool_use_id ) . toBe ( toolUseId )
705+ expect ( ( lastMsg . content [ 0 ] as any ) . content ) . toContain ( "Subtask c-new completed" )
706+ } )
544707} )
0 commit comments