@@ -872,6 +872,121 @@ describe("Cline", () => {
872872 await task . catch ( ( ) => { } )
873873 } )
874874
875+ it ( "should not duplicate retry messages when error already contains retry info" , async ( ) => {
876+ // Mock delay before creating the task
877+ const mockDelay = vi . fn ( ) . mockResolvedValue ( undefined )
878+ vi . mocked ( delay ) . mockImplementation ( mockDelay )
879+
880+ const [ cline , task ] = Task . create ( {
881+ provider : mockProvider ,
882+ apiConfiguration : mockApiConfig ,
883+ task : "test task" ,
884+ } )
885+
886+ // Mock say to track messages
887+ const saySpy = vi . spyOn ( cline , "say" ) . mockResolvedValue ( undefined )
888+
889+ // Create an error that already contains retry information
890+ const mockError = new Error ( "Engine loop is not running. Retry attempt 1\nRetrying now..." )
891+
892+ // Mock createMessage to fail first then succeed
893+ let callCount = 0
894+ vi . spyOn ( cline . api , "createMessage" ) . mockImplementation ( ( ) => {
895+ callCount ++
896+ if ( callCount === 1 ) {
897+ // First call fails - create a proper async iterator that throws
898+ const failedIterator = {
899+ [ Symbol . asyncIterator ] : ( ) => ( {
900+ next : async ( ) => {
901+ throw mockError
902+ } ,
903+ } ) ,
904+ }
905+ return failedIterator as any
906+ } else {
907+ // Subsequent calls succeed
908+ return {
909+ async * [ Symbol . asyncIterator ] ( ) {
910+ yield { type : "text" , text : "Success" } as ApiStreamChunk
911+ } ,
912+ } as any
913+ }
914+ } )
915+
916+ // Set alwaysApproveResubmit and requestDelaySeconds
917+ mockProvider . getState = vi . fn ( ) . mockResolvedValue ( {
918+ alwaysApproveResubmit : true ,
919+ autoApprovalEnabled : true ,
920+ requestDelaySeconds : 3 ,
921+ } )
922+
923+ // Mock previous API request message
924+ cline . clineMessages = [
925+ {
926+ ts : Date . now ( ) ,
927+ type : "say" ,
928+ say : "api_req_started" ,
929+ text : JSON . stringify ( {
930+ tokensIn : 100 ,
931+ tokensOut : 50 ,
932+ cacheWrites : 0 ,
933+ cacheReads : 0 ,
934+ request : "test request" ,
935+ } ) ,
936+ } ,
937+ ]
938+
939+ // Abandon the task to prevent hanging
940+ cline . abandoned = true
941+
942+ try {
943+ // Trigger API request - this will throw due to abandoned state
944+ const iterator = cline . attemptApiRequest ( 0 )
945+
946+ // Try to get the first value, which should trigger the error and retry logic
947+ try {
948+ await iterator . next ( )
949+ } catch ( e ) {
950+ // Expected to throw due to abandoned state
951+ }
952+
953+ // Wait a bit for async operations
954+ await new Promise ( ( resolve ) => setTimeout ( resolve , 100 ) )
955+
956+ // Calculate expected delay for first retry
957+ const baseDelay = 3 // from requestDelaySeconds
958+
959+ // Verify countdown messages don't include duplicate retry attempt info
960+ for ( let i = baseDelay ; i > 0 ; i -- ) {
961+ const calls = saySpy . mock . calls . filter (
962+ ( call ) =>
963+ call [ 0 ] === "api_req_retry_delayed" && call [ 1 ] ?. includes ( `Retrying in ${ i } seconds` ) ,
964+ )
965+ if ( calls . length > 0 ) {
966+ const message = calls [ 0 ] [ 1 ] as string
967+ // The error message contains "Retry attempt 1", but our code should not add another "Retry attempt X"
968+ // So we should only see one occurrence (from the original error)
969+ const retryAttemptMatches = ( message . match ( / R e t r y a t t e m p t / g) || [ ] ) . length
970+ expect ( retryAttemptMatches ) . toBe ( 1 ) // Only the one from the error message
971+ }
972+ }
973+
974+ // Verify final retry message
975+ const finalCalls = saySpy . mock . calls . filter (
976+ ( call ) => call [ 0 ] === "api_req_retry_delayed" && call [ 1 ] ?. includes ( "Retrying now" ) ,
977+ )
978+ if ( finalCalls . length > 0 ) {
979+ const finalMessage = finalCalls [ 0 ] [ 1 ] as string
980+ // Since the error already contains "Retrying now", our code should not add another one
981+ // So we should only see 1 occurrence total
982+ const retryingNowMatches = ( finalMessage . match ( / R e t r y i n g n o w / g) || [ ] ) . length
983+ expect ( retryingNowMatches ) . toBe ( 1 ) // Only one occurrence, not duplicated
984+ }
985+ } finally {
986+ await task . catch ( ( ) => { } )
987+ }
988+ } )
989+
875990 describe ( "processUserContentMentions" , ( ) => {
876991 it ( "should process mentions in task and feedback tags" , async ( ) => {
877992 const [ cline , task ] = Task . create ( {
0 commit comments