From 2d89959ac748b1bada8e88a0d8ad88d86eccbcef Mon Sep 17 00:00:00 2001 From: Ryan Joachim Date: Thu, 16 Jan 2025 02:33:22 -0800 Subject: [PATCH 1/5] Add maxApiRetry setting in UI and settings --- src/core/webview/ClineProvider.ts | 10 +++++++ .../webview/__tests__/ClineProvider.test.ts | 19 ++++++++++++++ src/shared/ExtensionMessage.ts | 1 + src/shared/WebviewMessage.ts | 1 + .../src/components/settings/SettingsView.tsx | 26 +++++++++++++++++++ .../src/context/ExtensionStateContext.tsx | 4 +++ 6 files changed, 61 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index dfd9246157c..14673543695 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -89,6 +89,7 @@ type GlobalStateKey = | "mcpEnabled" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "maxApiRetries" | "currentApiConfigName" | "listApiConfigMeta" | "mode" @@ -681,6 +682,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("requestDelaySeconds", message.value ?? 5) await this.postStateToWebview() break + case "maxApiRetries": + await this.updateGlobalState("maxApiRetries", message.value ?? 3) + await this.postStateToWebview() + break case "preferredLanguage": await this.updateGlobalState("preferredLanguage", message.text) await this.postStateToWebview() @@ -1479,6 +1484,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + maxApiRetries, currentApiConfigName, listApiConfigMeta, mode, @@ -1516,6 +1522,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + maxApiRetries: maxApiRetries ?? 3, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], mode: mode ?? codeMode, @@ -1626,6 +1633,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + maxApiRetries, currentApiConfigName, listApiConfigMeta, mode, @@ -1682,6 +1690,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("mcpEnabled") as Promise, this.getGlobalState("alwaysApproveResubmit") as Promise, this.getGlobalState("requestDelaySeconds") as Promise, + this.getGlobalState("maxApiRetries") as Promise, this.getGlobalState("currentApiConfigName") as Promise, this.getGlobalState("listApiConfigMeta") as Promise, this.getGlobalState("mode") as Promise, @@ -1783,6 +1792,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + maxApiRetries: maxApiRetries ?? 3, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], modeApiConfigs: modeApiConfigs ?? {} as Record, diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 4eddca2cd03..937c6daba40 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -274,6 +274,7 @@ describe('ClineProvider', () => { fuzzyMatchThreshold: 1.0, mcpEnabled: true, requestDelaySeconds: 5, + maxApiRetries: 3, mode: codeMode, } @@ -407,6 +408,19 @@ describe('ClineProvider', () => { expect(state.requestDelaySeconds).toBe(5) }) + test('maxApiRetries defaults to 3 retries', async () => { + // Mock globalState.get to return undefined for maxApiRetries + (mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { + if (key === 'maxApiRetries') { + return undefined + } + return null + }) + + const state = await provider.getState() + expect(state.maxApiRetries).toBe(3) + }) + test('alwaysApproveResubmit defaults to false', async () => { // Mock globalState.get to return undefined for alwaysApproveResubmit (mockContext.globalState.get as jest.Mock).mockReturnValue(undefined) @@ -502,6 +516,11 @@ describe('ClineProvider', () => { await messageHandler({ type: 'requestDelaySeconds', value: 10 }) expect(mockContext.globalState.update).toHaveBeenCalledWith('requestDelaySeconds', 10) expect(mockPostMessage).toHaveBeenCalled() + + // Test maxApiRetries + await messageHandler({ type: 'maxApiRetries', value: 5 }) + expect(mockContext.globalState.update).toHaveBeenCalledWith('maxApiRetries', 5) + expect(mockPostMessage).toHaveBeenCalled() }) test('file content includes line numbers', async () => { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 0a0ed860a17..0b84e4e54dc 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -69,6 +69,7 @@ export interface ExtensionState { alwaysAllowMcp?: boolean alwaysApproveResubmit?: boolean requestDelaySeconds: number + maxApiRetries: number uriScheme?: string allowedCommands?: string[] soundEnabled?: boolean diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 747f140e9ec..9da82b99e58 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -62,6 +62,7 @@ export interface WebviewMessage { | "requestDelaySeconds" | "setApiConfigPassword" | "mode" + | "maxApiRetries" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 13e3a8224f8..c6d118e3a58 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -57,6 +57,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setAlwaysApproveResubmit, requestDelaySeconds, setRequestDelaySeconds, + maxApiRetries, + setMaxApiRetries, currentApiConfigName, listApiConfigMeta, mode, @@ -96,6 +98,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled }) vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit }) vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds }) + vscode.postMessage({ type: "maxApiRetries", value: maxApiRetries }) vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName }) vscode.postMessage({ type: "upsertApiConfiguration", @@ -472,6 +475,29 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

Delay before retrying the request

+
+
+ setMaxApiRetries(parseInt(e.target.value))} + style={{ + flex: 1, + accentColor: 'var(--vscode-button-background)', + height: '2px' + }} + /> + + {maxApiRetries} tries + +
+

+ Maximum number of retry attempts for failed API requests +

+
)} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ddfe06842e6..e8867a7fe4d 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -55,6 +55,8 @@ export interface ExtensionStateContextType extends ExtensionState { setAlwaysApproveResubmit: (value: boolean) => void requestDelaySeconds: number setRequestDelaySeconds: (value: number) => void + maxApiRetries: number + setMaxApiRetries: (value: number) => void setCurrentApiConfigName: (value: string) => void setListApiConfigMeta: (value: ApiConfigMeta[]) => void onUpdateApiConfig: (apiConfig: ApiConfiguration) => void @@ -83,6 +85,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode mcpEnabled: true, alwaysApproveResubmit: false, requestDelaySeconds: 5, + maxApiRetries: 3, currentApiConfigName: 'default', listApiConfigMeta: [], mode: codeMode, @@ -226,6 +229,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), + setMaxApiRetries: (value) => setState((prevState) => ({ ...prevState, maxApiRetries: value })), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), setListApiConfigMeta, onUpdateApiConfig, From 32c5488e55cdef8cf0878c48075574bf90e9dfe6 Mon Sep 17 00:00:00 2001 From: Ryan Joachim Date: Thu, 16 Jan 2025 02:38:09 -0800 Subject: [PATCH 2/5] Unbreak PR commit...hopefully --- src/shared/WebviewMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 9da82b99e58..bf2fb2bdcc7 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -62,7 +62,7 @@ export interface WebviewMessage { | "requestDelaySeconds" | "setApiConfigPassword" | "mode" - | "maxApiRetries" + | "maxApiRetries" text?: string disabled?: boolean askResponse?: ClineAskResponse From e18419dc4175d6f766e1ee344b796a7b10a1bb54 Mon Sep 17 00:00:00 2001 From: Ryan Joachim Date: Thu, 16 Jan 2025 02:53:59 -0800 Subject: [PATCH 3/5] Revert "Unbreak PR commit...hopefully" This reverts commit 32c5488e55cdef8cf0878c48075574bf90e9dfe6. --- src/shared/WebviewMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index bf2fb2bdcc7..9da82b99e58 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -62,7 +62,7 @@ export interface WebviewMessage { | "requestDelaySeconds" | "setApiConfigPassword" | "mode" - | "maxApiRetries" + | "maxApiRetries" text?: string disabled?: boolean askResponse?: ClineAskResponse From ba7e2cbb2f3f07107a7758ef03a52005c558a709 Mon Sep 17 00:00:00 2001 From: Ryan Joachim Date: Thu, 16 Jan 2025 03:07:32 -0800 Subject: [PATCH 4/5] I'll admit...I broke git --- src/shared/WebviewMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 9da82b99e58..057acd4a38d 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -60,9 +60,9 @@ export interface WebviewMessage { | "searchCommits" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "maxApiRetries" | "setApiConfigPassword" | "mode" - | "maxApiRetries" text?: string disabled?: boolean askResponse?: ClineAskResponse From b5599249de378b6785e3cdf918bcc3469b39429e Mon Sep 17 00:00:00 2001 From: Ryan Joachim Date: Thu, 16 Jan 2025 05:18:39 -0800 Subject: [PATCH 5/5] feat: allow disabling API retries by setting maxApiRetries to 0 Modified the maxApiRetries slider in settings to accept 0 as minimum value, allowing users to completely disable automatic API retry attempts. Updated the aria-label to clarify that 0 means no limit will be applied. --- src/core/Cline.ts | 25 +- src/core/__tests__/Cline.test.ts | 388 ++++++++++++------ src/core/webview/ClineProvider.ts | 32 +- .../webview/__tests__/ClineProvider.test.ts | 72 ++-- src/shared/ExtensionMessage.ts | 2 +- .../src/components/settings/SettingsView.tsx | 5 +- .../settings/__tests__/SettingsView.test.tsx | 78 +++- .../src/context/ExtensionStateContext.tsx | 1 + 8 files changed, 401 insertions(+), 202 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 9cb3a3c5f99..953e374cbcd 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -79,6 +79,7 @@ export class Cline { private askResponseImages?: string[] private lastMessageTs?: number private consecutiveMistakeCount: number = 0 + private apiRetryCount: number = 0 private consecutiveMistakeCountForApplyDiff: Map = new Map() private providerRef: WeakRef private abort: boolean = false @@ -841,12 +842,22 @@ 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 { maxApiRetries } = await this.providerRef.deref()?.getState() ?? {} + const currentRetryCount = (this.apiRetryCount || 0) + 1 + this.apiRetryCount = currentRetryCount + + if (maxApiRetries !== undefined && maxApiRetries !== 0 && currentRetryCount > maxApiRetries) { + const errorMsg = `Maximum retry attempts (${maxApiRetries}) reached. ${error.message ?? "Unknown error"}` + await this.say("error", errorMsg) + throw error + } + const errorMsg = error.message ?? "Unknown error" const requestDelay = requestDelaySeconds || 5 // Automatically retry with delay // 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 this.say("api_req_retry_delayed", `${errorMsg}\n\nRetrying in ${i} seconds...${maxApiRetries !== undefined && maxApiRetries > 0 ? ` (Attempt ${currentRetryCount} of ${maxApiRetries})` : ''}`, undefined, true) await delay(1000) } await this.say("api_req_retry_delayed", `${errorMsg}\n\nRetrying now...`, undefined, false) @@ -1318,9 +1329,9 @@ export class Cline { // Apply the diff to the original content const diffResult = this.diffStrategy?.applyDiff( - originalContent, - diffContent, - parseInt(block.params.start_line ?? ''), + originalContent, + diffContent, + parseInt(block.params.start_line ?? ''), parseInt(block.params.end_line ?? '') ) ?? { success: false, @@ -2062,7 +2073,7 @@ export class Cline { } /* - Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present. + Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present. When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI. */ this.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked @@ -2558,7 +2569,7 @@ export class Cline { const { mode } = await this.providerRef.deref()?.getState() ?? {} const currentMode = mode ?? codeMode details += `\n\n# Current Mode\n${currentMode}` - + // Add warning if not in code mode if (!isToolAllowedForMode('write_to_file', currentMode) || !isToolAllowedForMode('execute_command', currentMode)) { details += `\n\nNOTE: You are currently in '${currentMode}' mode which only allows read-only operations. To write files or execute commands, the user will need to switch to 'code' mode. Note that only the user can switch modes.` @@ -2579,4 +2590,4 @@ export class Cline { return `\n${details.trim()}\n` } -} \ No newline at end of file +} diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 66bdbf7ddbc..7230e98f8ae 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -46,7 +46,7 @@ jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ // Mock fileExistsAtPath jest.mock('../../utils/fs', () => ({ fileExistsAtPath: jest.fn().mockImplementation((filePath) => { - return filePath.includes('ui_messages.json') || + return filePath.includes('ui_messages.json') || filePath.includes('api_conversation_history.json'); }) })); @@ -191,7 +191,7 @@ describe('Cline', () => { let mockApiConfig: ApiConfiguration; let mockOutputChannel: any; let mockExtensionContext: vscode.ExtensionContext; - + beforeEach(() => { // Setup mock extension context mockExtensionContext = { @@ -249,7 +249,7 @@ describe('Cline', () => { // Setup mock provider with output channel mockProvider = new ClineProvider(mockExtensionContext, mockOutputChannel) as jest.Mocked; - + // Setup mock API configuration mockApiConfig = { apiProvider: 'anthropic', @@ -310,7 +310,7 @@ describe('Cline', () => { it('should use provided fuzzy match threshold', () => { const getDiffStrategySpy = jest.spyOn(require('../diff/DiffStrategy'), 'getDiffStrategy'); - + const cline = new Cline( mockProvider, mockApiConfig, @@ -323,13 +323,13 @@ describe('Cline', () => { expect(cline.diffEnabled).toBe(true); expect(cline.diffStrategy).toBeDefined(); expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 0.9); - + getDiffStrategySpy.mockRestore(); }); it('should pass default threshold to diff strategy when not provided', () => { const getDiffStrategySpy = jest.spyOn(require('../diff/DiffStrategy'), 'getDiffStrategy'); - + const cline = new Cline( mockProvider, mockApiConfig, @@ -342,7 +342,7 @@ describe('Cline', () => { expect(cline.diffEnabled).toBe(true); expect(cline.diffStrategy).toBeDefined(); expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 1.0); - + getDiffStrategySpy.mockRestore(); }); @@ -379,7 +379,7 @@ describe('Cline', () => { return mockDate.getTime(); } } - + global.Date = MockDate as DateConstructor; // Create a proper mock of Intl.DateTimeFormat @@ -415,14 +415,14 @@ describe('Cline', () => { ); const details = await cline['getEnvironmentDetails'](false); - + // Verify timezone information is present and formatted correctly expect(details).toContain('America/Los_Angeles'); expect(details).toMatch(/UTC-7:00/); // Fixed offset for America/Los_Angeles expect(details).toContain('# Current Time'); expect(details).toMatch(/1\/1\/2024.*5:00:00 AM.*\(America\/Los_Angeles, UTC-7:00\)/); // Full time string format }); - + describe('API conversation handling', () => { it('should clean conversation history before sending to API', async () => { const cline = new Cline( @@ -433,7 +433,7 @@ describe('Cline', () => { undefined, 'test task' ); - + // Mock the API's createMessage method to capture the conversation history const createMessageSpy = jest.fn(); const mockStream = { @@ -453,7 +453,7 @@ describe('Cline', () => { // Cleanup } } as AsyncGenerator; - + jest.spyOn(cline.api, 'createMessage').mockImplementation((...args) => { createMessageSpy(...args); return mockStream; @@ -475,7 +475,7 @@ describe('Cline', () => { // Get all calls to createMessage const calls = createMessageSpy.mock.calls; - + // Find the call that includes our test message const relevantCall = calls.find(call => call[1]?.some((msg: any) => @@ -627,132 +627,252 @@ describe('Cline', () => { text: '[Referenced image in conversation]' }); }); - - 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); + describe('API retry behavior', () => { + let mockDelay: jest.Mock; + let mockError: Error; + let mockFailedStream: AsyncGenerator; + let mockSuccessStream: AsyncGenerator; + + beforeEach(() => { + // Mock delay to track countdown timing + mockDelay = jest.fn().mockResolvedValue(undefined); + jest.spyOn(require('delay'), 'default').mockImplementation(mockDelay); + + // Create a stream that fails on first chunk + mockError = new Error('API Error'); + 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 + 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 say to track messages - const saySpy = jest.spyOn(cline, 'say'); + it('should allow unlimited retries when maxApiRetries is 0', async () => { + const cline = new Cline( + mockProvider, + mockApiConfig, + undefined, + false, + undefined, + 'test task' + ); - // 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; + const saySpy = jest.spyOn(cline, 'say'); - // 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 always fail + jest.spyOn(cline.api, 'createMessage').mockReturnValue(mockFailedStream); + + // Set maxApiRetries to 0 (unlimited) and enable auto retry + mockProvider.getState = jest.fn().mockResolvedValue({ + alwaysApproveResubmit: true, + requestDelaySeconds: 1, + maxApiRetries: 0 + }); - // Mock createMessage to fail first then succeed - let firstAttempt = true; - jest.spyOn(cline.api, 'createMessage').mockImplementation(() => { - if (firstAttempt) { - firstAttempt = false; - return mockFailedStream; + // 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); + + // Let it retry several times to verify no limit + for (let i = 0; i < 10; i++) { + try { + await iterator.next(); + } catch (error) { + // Ignore errors as we expect them + } } - return mockSuccessStream; + + // Verify it attempted more retries than the usual limit + expect(cline.api.createMessage).toHaveBeenCalledTimes(11); // Initial attempt + 10 retries + + // Verify retry messages don't mention attempt numbers + expect(saySpy).toHaveBeenCalledWith( + 'api_req_retry_delayed', + expect.stringContaining('Retrying in 1 seconds...'), + undefined, + true + ); + expect(saySpy).not.toHaveBeenCalledWith( + 'api_req_retry_delayed', + expect.stringContaining('Attempt'), + undefined, + true + ); }); - // Set alwaysApproveResubmit and requestDelaySeconds - mockProvider.getState = jest.fn().mockResolvedValue({ - alwaysApproveResubmit: true, - requestDelaySeconds: 3 + it('should handle API retry with countdown', async () => { + const cline = new Cline( + mockProvider, + mockApiConfig, + undefined, + false, + undefined, + 'test task' + ); + + const saySpy = jest.spyOn(cline, 'say'); + + // 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, + maxApiRetries: 2 + }); + + // 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... (Attempt 1 of 2)'), + undefined, + true + ); + expect(saySpy).toHaveBeenCalledWith( + 'api_req_retry_delayed', + expect.stringContaining('Retrying in 2 seconds... (Attempt 1 of 2)'), + undefined, + true + ); + expect(saySpy).toHaveBeenCalledWith( + 'api_req_retry_delayed', + expect.stringContaining('Retrying in 1 seconds... (Attempt 1 of 2)'), + 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); }); - // 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 - ); + it('should respect maxApiRetries limit', async () => { + const cline = new Cline( + mockProvider, + mockApiConfig, + undefined, + false, + undefined, + 'test task' + ); - // Verify delay was called correctly - expect(mockDelay).toHaveBeenCalledTimes(3); - expect(mockDelay).toHaveBeenCalledWith(1000); + const saySpy = jest.spyOn(cline, 'say'); - // 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...`); + // Mock createMessage to always fail + jest.spyOn(cline.api, 'createMessage').mockReturnValue(mockFailedStream); + + // Set maxApiRetries to 2 + mockProvider.getState = jest.fn().mockResolvedValue({ + alwaysApproveResubmit: true, + requestDelaySeconds: 1, + maxApiRetries: 2 + }); + + // 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 and expect it to throw after maxApiRetries + const iterator = cline.attemptApiRequest(0); + await expect(iterator.next()).rejects.toThrow('Maximum retry attempts (2) reached'); + + // Verify error message was shown + expect(saySpy).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Maximum retry attempts (2) reached'), + undefined + ); + + // Verify the correct number of retries were attempted + expect(cline.api.createMessage).toHaveBeenCalledTimes(3); // Initial attempt + 2 retries + }); }); describe('loadContext', () => { @@ -765,11 +885,11 @@ describe('Cline', () => { undefined, 'test task' ); - + // Mock parseMentions to track calls const mockParseMentions = jest.fn().mockImplementation(text => `processed: ${text}`); jest.spyOn(require('../../core/mentions'), 'parseMentions').mockImplementation(mockParseMentions); - + const userContent = [ { type: 'text', @@ -796,14 +916,14 @@ describe('Cline', () => { }] } as Anthropic.ToolResultBlockParam ]; - + // Process the content const [processedContent] = await cline['loadContext'](userContent); - + // Regular text should not be processed expect((processedContent[0] as Anthropic.TextBlockParam).text) .toBe('Regular text with @/some/path'); - + // Text within task tags should be processed expect((processedContent[1] as Anthropic.TextBlockParam).text) .toContain('processed:'); @@ -812,7 +932,7 @@ describe('Cline', () => { expect.any(String), expect.any(Object) ); - + // Feedback tag content should be processed const toolResult1 = processedContent[2] as Anthropic.ToolResultBlockParam; const content1 = Array.isArray(toolResult1.content) ? toolResult1.content[0] : toolResult1.content; @@ -822,7 +942,7 @@ describe('Cline', () => { expect.any(String), expect.any(Object) ); - + // Regular tool result should not be processed const toolResult2 = processedContent[3] as Anthropic.ToolResultBlockParam; const content2 = Array.isArray(toolResult2.content) ? toolResult2.content[0] : toolResult2.content; diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 14673543695..4c24fab070a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -683,7 +683,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.postStateToWebview() break case "maxApiRetries": - await this.updateGlobalState("maxApiRetries", message.value ?? 3) + await this.updateGlobalState("maxApiRetries", message.value ?? 0) await this.postStateToWebview() break case "preferredLanguage": @@ -701,14 +701,14 @@ export class ClineProvider implements vscode.WebviewViewProvider { case "mode": const newMode = message.text as Mode await this.updateGlobalState("mode", newMode) - + // Load the saved API config for the new mode if it exists const savedConfigId = await this.configManager.GetModeConfigId(newMode) const listApiConfig = await this.configManager.ListConfig() - + // Update listApiConfigMeta first to ensure UI has latest data await this.updateGlobalState("listApiConfigMeta", listApiConfig) - + // If this mode has a saved config, use it if (savedConfigId) { const config = listApiConfig?.find(c => c.id === savedConfigId) @@ -729,7 +729,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { } } } - + await this.postStateToWebview() break case "deleteMessage": { @@ -744,16 +744,16 @@ export class ClineProvider implements vscode.WebviewViewProvider { const timeCutoff = message.value - 1000; // 1 second buffer before the message to delete const messageIndex = this.cline.clineMessages.findIndex(msg => msg.ts && msg.ts >= timeCutoff) const apiConversationHistoryIndex = this.cline.apiConversationHistory.findIndex(msg => msg.ts && msg.ts >= timeCutoff) - + if (messageIndex !== -1) { const { historyItem } = await this.getTaskWithId(this.cline.taskId) - + if (answer === "Just this message") { // Find the next user message first const nextUserMessage = this.cline.clineMessages .slice(messageIndex + 1) .find(msg => msg.type === "say" && msg.say === "user_feedback") - + // Handle UI messages if (nextUserMessage) { // Find absolute index of next user message @@ -769,7 +769,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.cline.clineMessages.slice(0, messageIndex) ) } - + // Handle API messages if (apiConversationHistoryIndex !== -1) { if (nextUserMessage && nextUserMessage.ts) { @@ -792,7 +792,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.cline.overwriteApiConversationHistory(this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex)) } } - + await this.initClineWithHistoryItem(historyItem) } } @@ -845,7 +845,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { try { await this.configManager.SaveConfig(message.text, message.apiConfiguration); let listApiConfig = await this.configManager.ListConfig(); - + // Update listApiConfigMeta first to ensure UI has latest data await this.updateGlobalState("listApiConfigMeta", listApiConfig); @@ -871,7 +871,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { let listApiConfig = await this.configManager.ListConfig(); const config = listApiConfig?.find(c => c.name === newName); - + // Update listApiConfigMeta first to ensure UI has latest data await this.updateGlobalState("listApiConfigMeta", listApiConfig); @@ -892,7 +892,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { const apiConfig = await this.configManager.LoadConfig(message.text); const listApiConfig = await this.configManager.ListConfig(); const config = listApiConfig?.find(c => c.name === message.text); - + // Update listApiConfigMeta first to ensure UI has latest data await this.updateGlobalState("listApiConfigMeta", listApiConfig); @@ -923,7 +923,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { try { await this.configManager.DeleteConfig(message.text); const listApiConfig = await this.configManager.ListConfig(); - + // Update listApiConfigMeta first to ensure UI has latest data await this.updateGlobalState("listApiConfigMeta", listApiConfig); @@ -1037,7 +1037,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform) if (this.cline) { this.cline.api = buildApiHandler(apiConfiguration) - } + } } async updateCustomInstructions(instructions?: string) { @@ -1792,7 +1792,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, - maxApiRetries: maxApiRetries ?? 3, + maxApiRetries: maxApiRetries ?? 0, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], modeApiConfigs: modeApiConfigs ?? {} as Record, diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 937c6daba40..96d4265fd84 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -240,7 +240,7 @@ describe('ClineProvider', () => { test('resolveWebviewView sets up webview correctly', () => { provider.resolveWebviewView(mockWebviewView) - + expect(mockWebviewView.webview.options).toEqual({ enableScripts: true, localResourceRoots: [mockContext.extensionUri] @@ -250,7 +250,7 @@ describe('ClineProvider', () => { test('postMessageToWebview sends message to webview', async () => { provider.resolveWebviewView(mockWebviewView) - + const mockState: ExtensionState = { version: '1.0.0', preferredLanguage: 'English', @@ -274,16 +274,16 @@ describe('ClineProvider', () => { fuzzyMatchThreshold: 1.0, mcpEnabled: true, requestDelaySeconds: 5, - maxApiRetries: 3, + maxApiRetries: 0, mode: codeMode, } - - const message: ExtensionMessage = { - type: 'state', + + const message: ExtensionMessage = { + type: 'state', state: mockState } await provider.postMessageToWebview(message) - + expect(mockPostMessage).toHaveBeenCalledWith(message) }) @@ -314,7 +314,7 @@ describe('ClineProvider', () => { test('getState returns correct initial state', async () => { const state = await provider.getState() - + expect(state).toHaveProperty('apiConfiguration') expect(state.apiConfiguration).toHaveProperty('apiProvider') expect(state).toHaveProperty('customInstructions') @@ -331,7 +331,7 @@ describe('ClineProvider', () => { test('preferredLanguage defaults to VSCode language when not set', async () => { // Mock VSCode language as Spanish (vscode.env as any).language = 'es-ES'; - + const state = await provider.getState(); expect(state.preferredLanguage).toBe('Spanish'); }) @@ -339,7 +339,7 @@ describe('ClineProvider', () => { test('preferredLanguage defaults to English for unsupported VSCode language', async () => { // Mock VSCode language as an unsupported language (vscode.env as any).language = 'unsupported-LANG'; - + const state = await provider.getState(); expect(state.preferredLanguage).toBe('English'); }) @@ -347,9 +347,9 @@ describe('ClineProvider', () => { test('diffEnabled defaults to true when not set', async () => { // Mock globalState.get to return undefined for diffEnabled (mockContext.globalState.get as jest.Mock).mockReturnValue(undefined) - + const state = await provider.getState() - + expect(state.diffEnabled).toBe(true) }) @@ -361,7 +361,7 @@ describe('ClineProvider', () => { } return null }) - + const state = await provider.getState() expect(state.writeDelayMs).toBe(1000) }) @@ -369,9 +369,9 @@ describe('ClineProvider', () => { test('handles writeDelayMs message', async () => { provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - + await messageHandler({ type: 'writeDelayMs', value: 2000 }) - + expect(mockContext.globalState.update).toHaveBeenCalledWith('writeDelayMs', 2000) expect(mockPostMessage).toHaveBeenCalled() }) @@ -408,17 +408,41 @@ describe('ClineProvider', () => { expect(state.requestDelaySeconds).toBe(5) }) - test('maxApiRetries defaults to 3 retries', async () => { - // Mock globalState.get to return undefined for maxApiRetries - (mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { + test('maxApiRetries is optional and defaults to 0 (off)', async () => { + const getMock = jest.fn(); + + // Test when maxApiRetries is undefined + getMock.mockImplementation((key: string) => { if (key === 'maxApiRetries') { - return undefined + return undefined; } - return null - }) - - const state = await provider.getState() - expect(state.maxApiRetries).toBe(3) + return null; + }); + mockContext.globalState.get = getMock; + const stateUndefined = await provider.getState(); + expect(stateUndefined.maxApiRetries).toBe(0); + + // Test when maxApiRetries is explicitly set + getMock.mockImplementation((key: string) => { + if (key === 'maxApiRetries') { + return 5; + } + return null; + }); + mockContext.globalState.get = getMock; + const stateSet = await provider.getState(); + expect(stateSet.maxApiRetries).toBe(5); + + // Test that maxApiRetries can be null/undefined without breaking + getMock.mockImplementation((key: string) => { + if (key === 'maxApiRetries') { + return null; + } + return null; + }); + mockContext.globalState.get = getMock; + const stateNull = await provider.getState(); + expect(stateNull.maxApiRetries).toBe(0); }) test('alwaysApproveResubmit defaults to false', async () => { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 0b84e4e54dc..8afa11aaeee 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -69,7 +69,7 @@ export interface ExtensionState { alwaysAllowMcp?: boolean alwaysApproveResubmit?: boolean requestDelaySeconds: number - maxApiRetries: number + maxApiRetries?: number uriScheme?: string allowedCommands?: string[] soundEnabled?: boolean diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index c6d118e3a58..8932ee19b05 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -479,7 +479,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
{ accentColor: 'var(--vscode-button-background)', height: '2px' }} + aria-label="Maximum number of retry attempts (0 for no limit)" /> - {maxApiRetries} tries + {maxApiRetries === 0 ? 'No Limit' : `${maxApiRetries} tries`}

diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx index a82e85a6930..9863d11c8b1 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx @@ -24,7 +24,7 @@ jest.mock('../ApiConfigManager', () => ({ // Mock VSCode components jest.mock('@vscode/webview-ui-toolkit/react', () => ({ VSCodeButton: ({ children, onClick, appearance }: any) => ( - appearance === 'icon' ? + appearance === 'icon' ? : @@ -122,31 +122,31 @@ describe('SettingsView - Sound Settings', () => { it('initializes with sound disabled by default', () => { renderSettingsView() - + const soundCheckbox = screen.getByRole('checkbox', { name: /Enable sound effects/i }) expect(soundCheckbox).not.toBeChecked() - + // Volume slider should not be visible when sound is disabled expect(screen.queryByRole('slider', { name: /volume/i })).not.toBeInTheDocument() }) it('toggles sound setting and sends message to VSCode', () => { renderSettingsView() - + const soundCheckbox = screen.getByRole('checkbox', { name: /Enable sound effects/i }) - + // Enable sound fireEvent.click(soundCheckbox) expect(soundCheckbox).toBeChecked() - + // Click Done to save settings const doneButton = screen.getByText('Done') fireEvent.click(doneButton) - + expect(vscode.postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: 'soundEnabled', @@ -157,7 +157,7 @@ describe('SettingsView - Sound Settings', () => { it('shows volume slider when sound is enabled', () => { renderSettingsView() - + // Enable sound const soundCheckbox = screen.getByRole('checkbox', { name: /Enable sound effects/i @@ -172,7 +172,7 @@ describe('SettingsView - Sound Settings', () => { it('updates volume and sends message to VSCode when slider changes', () => { renderSettingsView() - + // Enable sound const soundCheckbox = screen.getByRole('checkbox', { name: /Enable sound effects/i @@ -202,7 +202,7 @@ describe('SettingsView - API Configuration', () => { it('renders ApiConfigManagement with correct props', () => { renderSettingsView() - + expect(screen.getByTestId('api-config-management')).toBeInTheDocument() }) }) @@ -214,7 +214,7 @@ describe('SettingsView - Allowed Commands', () => { it('shows allowed commands section when alwaysAllowExecute is enabled', () => { renderSettingsView() - + // Enable always allow execute const executeCheckbox = screen.getByRole('checkbox', { name: /Always approve allowed execute operations/i @@ -228,7 +228,7 @@ describe('SettingsView - Allowed Commands', () => { it('adds new command to the list', () => { renderSettingsView() - + // Enable always allow execute const executeCheckbox = screen.getByRole('checkbox', { name: /Always approve allowed execute operations/i @@ -238,13 +238,13 @@ describe('SettingsView - Allowed Commands', () => { // Add a new command const input = screen.getByPlaceholderText(/Enter command prefix/i) fireEvent.change(input, { target: { value: 'npm test' } }) - + const addButton = screen.getByText('Add') fireEvent.click(addButton) // Verify command was added expect(screen.getByText('npm test')).toBeInTheDocument() - + // Verify VSCode message was sent expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'allowedCommands', @@ -254,7 +254,7 @@ describe('SettingsView - Allowed Commands', () => { it('removes command from the list', () => { renderSettingsView() - + // Enable always allow execute const executeCheckbox = screen.getByRole('checkbox', { name: /Always approve allowed execute operations/i @@ -273,7 +273,7 @@ describe('SettingsView - Allowed Commands', () => { // Verify command was removed expect(screen.queryByText('npm test')).not.toBeInTheDocument() - + // Verify VSCode message was sent expect(vscode.postMessage).toHaveBeenLastCalledWith({ type: 'allowedCommands', @@ -283,7 +283,7 @@ describe('SettingsView - Allowed Commands', () => { it('prevents duplicate commands', () => { renderSettingsView() - + // Enable always allow execute const executeCheckbox = screen.getByRole('checkbox', { name: /Always approve allowed execute operations/i @@ -309,7 +309,7 @@ describe('SettingsView - Allowed Commands', () => { it('saves allowed commands when clicking Done', () => { const { onDone } = renderSettingsView() - + // Enable always allow execute const executeCheckbox = screen.getByRole('checkbox', { name: /Always approve allowed execute operations/i @@ -334,3 +334,45 @@ describe('SettingsView - Allowed Commands', () => { expect(onDone).toHaveBeenCalled() }) }) + +describe('SettingsView - API Retry Settings', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('shows "No Limit" when maxApiRetries is 0', () => { + renderSettingsView() + + // Enable auto retry + const retryCheckbox = screen.getByRole('checkbox', { + name: /Always retry failed API requests/i + }) + fireEvent.click(retryCheckbox) + + // Set maxApiRetries to 0 + const retrySlider = screen.getByRole('slider', { name: /Maximum number of retry attempts/i }) + fireEvent.change(retrySlider, { target: { value: '0' } }) + + // Verify "No Limit" text is shown + expect(screen.getByText('No Limit')).toBeInTheDocument() + expect(screen.queryByText('0 tries')).not.toBeInTheDocument() + }) + + it('shows number of tries when maxApiRetries is greater than 0', () => { + renderSettingsView() + + // Enable auto retry + const retryCheckbox = screen.getByRole('checkbox', { + name: /Always retry failed API requests/i + }) + fireEvent.click(retryCheckbox) + + // Set maxApiRetries to 5 + const retrySlider = screen.getByRole('slider', { name: /Maximum number of retry attempts/i }) + fireEvent.change(retrySlider, { target: { value: '5' } }) + + // Verify "5 tries" text is shown + expect(screen.getByText('5 tries')).toBeInTheDocument() + expect(screen.queryByText('Disabled')).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index e8867a7fe4d..833e90ec565 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -205,6 +205,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode fuzzyMatchThreshold: state.fuzzyMatchThreshold, writeDelayMs: state.writeDelayMs, screenshotQuality: state.screenshotQuality, + maxApiRetries: state.maxApiRetries ?? 3, setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value