diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 6cbc925..15ecdca 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -833,15 +833,15 @@ export class Cline { } catch (error) { // note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely. if (alwaysApproveResubmit) { + const errorMsg = error.message ?? "Unknown error" const requestDelay = requestDelaySeconds || 5 // Automatically retry with delay - await this.say( - "error", - `${error.message ?? "Unknown error"} ↺ Retrying in ${requestDelay} seconds...`, - ) - await this.say("api_req_retry_delayed") - await delay(requestDelay * 1000) - await this.say("api_req_retried") + // Show countdown timer in error color + for (let i = requestDelay; i > 0; i--) { + await this.say("api_req_retry_delayed", `${errorMsg}\n\nRetrying in ${i} seconds...`, undefined, true) + await delay(1000) + } + await this.say("api_req_retry_delayed", `${errorMsg}\n\nRetrying now...`, undefined, false) // delegate generator output from the recursive call yield* this.attemptApiRequest(previousApiReqIndex) return diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index b77fc3d..66bdbf7 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -628,6 +628,133 @@ describe('Cline', () => { }); }); + it('should handle API retry with countdown', async () => { + const cline = new Cline( + mockProvider, + mockApiConfig, + undefined, + false, + undefined, + 'test task' + ); + + // Mock delay to track countdown timing + const mockDelay = jest.fn().mockResolvedValue(undefined); + jest.spyOn(require('delay'), 'default').mockImplementation(mockDelay); + + // Mock say to track messages + const saySpy = jest.spyOn(cline, 'say'); + + // Create a stream that fails on first chunk + const mockError = new Error('API Error'); + const mockFailedStream = { + async *[Symbol.asyncIterator]() { + throw mockError; + }, + async next() { + throw mockError; + }, + async return() { + return { done: true, value: undefined }; + }, + async throw(e: any) { + throw e; + }, + async [Symbol.asyncDispose]() { + // Cleanup + } + } as AsyncGenerator; + + // Create a successful stream for retry + const mockSuccessStream = { + async *[Symbol.asyncIterator]() { + yield { type: 'text', text: 'Success' }; + }, + async next() { + return { done: true, value: { type: 'text', text: 'Success' } }; + }, + async return() { + return { done: true, value: undefined }; + }, + async throw(e: any) { + throw e; + }, + async [Symbol.asyncDispose]() { + // Cleanup + } + } as AsyncGenerator; + + // Mock createMessage to fail first then succeed + let firstAttempt = true; + jest.spyOn(cline.api, 'createMessage').mockImplementation(() => { + if (firstAttempt) { + firstAttempt = false; + return mockFailedStream; + } + return mockSuccessStream; + }); + + // Set alwaysApproveResubmit and requestDelaySeconds + mockProvider.getState = jest.fn().mockResolvedValue({ + alwaysApproveResubmit: true, + requestDelaySeconds: 3 + }); + + // Mock previous API request message + cline.clineMessages = [{ + ts: Date.now(), + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ + tokensIn: 100, + tokensOut: 50, + cacheWrites: 0, + cacheReads: 0, + request: 'test request' + }) + }]; + + // Trigger API request + const iterator = cline.attemptApiRequest(0); + await iterator.next(); + + // Verify countdown messages + expect(saySpy).toHaveBeenCalledWith( + 'api_req_retry_delayed', + expect.stringContaining('Retrying in 3 seconds'), + undefined, + true + ); + expect(saySpy).toHaveBeenCalledWith( + 'api_req_retry_delayed', + expect.stringContaining('Retrying in 2 seconds'), + undefined, + true + ); + expect(saySpy).toHaveBeenCalledWith( + 'api_req_retry_delayed', + expect.stringContaining('Retrying in 1 seconds'), + undefined, + true + ); + expect(saySpy).toHaveBeenCalledWith( + 'api_req_retry_delayed', + expect.stringContaining('Retrying now'), + undefined, + false + ); + + // Verify delay was called correctly + expect(mockDelay).toHaveBeenCalledTimes(3); + expect(mockDelay).toHaveBeenCalledWith(1000); + + // Verify error message content + const errorMessage = saySpy.mock.calls.find( + call => call[1]?.includes(mockError.message) + )?.[1]; + expect(errorMessage).toBe(`${mockError.message}\n\nRetrying in 3 seconds...`); + }); + describe('loadContext', () => { it('should process mentions in task and feedback tags', async () => { const cline = new Cline( diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 0253850..21b5c34 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -154,6 +154,8 @@ export const ChatRowContent = ({ style={{ color: successColor, marginBottom: "-1.5px" }}>, Task Completed, ] + case "api_req_retry_delayed": + return [] case "api_req_started": const getIconSpan = (iconName: string, color: string) => (