diff --git a/.vscode/launch.json b/.vscode/launch.json index ead17002914..871fedc59a2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,9 +10,7 @@ "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}", - ], + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], "sourceMaps": true, "outFiles": ["${workspaceFolder}/dist/**/*.js"], "preLaunchTask": "compile", @@ -20,10 +18,7 @@ "NODE_ENV": "development", "VSCODE_DEBUG_MODE": "true" }, - "resolveSourceMapLocations": [ - "${workspaceFolder}/**", - "!**/node_modules/**" - ] - }, + "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"] + } ] } diff --git a/cline_docs/settings.md b/cline_docs/settings.md index f4b06826023..793aa9986a2 100644 --- a/cline_docs/settings.md +++ b/cline_docs/settings.md @@ -56,10 +56,9 @@ - Example: ```typescript setMultisearchDiffEnabled(e.target.checked)} - > - Enable multi-search diff matching + checked={multisearchDiffEnabled} + onChange={(e: any) => setMultisearchDiffEnabled(e.target.checked)}> + Enable multi-search diff matching ``` @@ -116,19 +115,19 @@ - Example: ```typescript ``` diff --git a/src/api/transform/vscode-lm-format.ts b/src/api/transform/vscode-lm-format.ts index 6d7bea92bad..a17903a9d7f 100644 --- a/src/api/transform/vscode-lm-format.ts +++ b/src/api/transform/vscode-lm-format.ts @@ -73,7 +73,9 @@ export function convertToVsCodeLmMessages( : (toolMessage.content?.map((part) => { if (part.type === "image") { return new vscode.LanguageModelTextPart( - `[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`, + `[Image (${part.source?.type || "Unknown source-type"}): ${ + part.source?.media_type || "unknown media-type" + } not supported by VSCode LM API]`, ) } return new vscode.LanguageModelTextPart(part.text) @@ -86,7 +88,9 @@ export function convertToVsCodeLmMessages( ...nonToolMessages.map((part) => { if (part.type === "image") { return new vscode.LanguageModelTextPart( - `[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`, + `[Image (${part.source?.type || "Unknown source-type"}): ${ + part.source?.media_type || "unknown media-type" + } not supported by VSCode LM API]`, ) } return new vscode.LanguageModelTextPart(part.text) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 5f8af779857..ed93047bde0 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -88,6 +88,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 @@ -884,6 +885,18 @@ 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 @@ -2142,7 +2155,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 diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 2f46456de26..eb1d46a10a8 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -645,124 +645,236 @@ describe("Cline", () => { }) }) - it("should handle API retry with countdown", async () => { - const cline = new Cline(mockProvider, mockApiConfig, undefined, false, undefined, "test task") + 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 delay to track countdown timing - const mockDelay = jest.fn().mockResolvedValue(undefined) - jest.spyOn(require("delay"), "default").mockImplementation(mockDelay) + it("should allow unlimited retries when maxApiRetries is 0", async () => { + const cline = new Cline(mockProvider, mockApiConfig, undefined, false, undefined, "test task") - // Mock say to track messages - const saySpy = jest.spyOn(cline, "say") + 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 + // Mock createMessage to always fail + jest.spyOn(cline.api, "createMessage").mockReturnValue(mockFailedStream) - // 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 + // Set maxApiRetries to 0 (unlimited) and enable auto retry + mockProvider.getState = jest.fn().mockResolvedValue({ + alwaysApproveResubmit: true, + requestDelaySeconds: 1, + maxApiRetries: 0, + }) + + // 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", - }), - }, - ] + it("should respect maxApiRetries limit", async () => { + const cline = new Cline(mockProvider, mockApiConfig, undefined, false, undefined, "test task") - // Trigger API request - const iterator = cline.attemptApiRequest(0) - await iterator.next() + const saySpy = jest.spyOn(cline, "say") - // 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, - ) + // Mock createMessage to always fail + jest.spyOn(cline.api, "createMessage").mockReturnValue(mockFailedStream) - // Verify delay was called correctly - expect(mockDelay).toHaveBeenCalledTimes(3) - expect(mockDelay).toHaveBeenCalledWith(1000) + // Set maxApiRetries to 2 + mockProvider.getState = jest.fn().mockResolvedValue({ + alwaysApproveResubmit: true, + requestDelaySeconds: 1, + maxApiRetries: 2, + }) - // 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 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", () => { diff --git a/src/core/diff/strategies/search-replace.ts b/src/core/diff/strategies/search-replace.ts index 1ede3c3238e..8a01c6f17ce 100644 --- a/src/core/diff/strategies/search-replace.ts +++ b/src/core/diff/strategies/search-replace.ts @@ -273,7 +273,15 @@ Your search/replace content here : "" return { success: false, - error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine && endLine ? `lines ${startLine}-${endLine}` : "start to end"}\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`, + error: `No sufficiently similar match found${lineRange} (${Math.floor( + bestMatchScore * 100, + )}% similar, needs ${Math.floor( + this.fuzzyThreshold * 100, + )}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor( + bestMatchScore * 100, + )}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${ + startLine && endLine ? `lines ${startLine}-${endLine}` : "start to end" + }\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`, } } diff --git a/src/core/prompts/sections/mcp-servers.ts b/src/core/prompts/sections/mcp-servers.ts index 774d7dfd638..43e290259bb 100644 --- a/src/core/prompts/sections/mcp-servers.ts +++ b/src/core/prompts/sections/mcp-servers.ts @@ -34,7 +34,9 @@ export async function getMcpServersSection(mcpHub?: McpHub, diffStrategy?: DiffS const config = JSON.parse(server.config) return ( - `## ${server.name} (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)` + + `## ${server.name} (\`${config.command}${ + config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : "" + }\`)` + (tools ? `\n\n### Available Tools\n${tools}` : "") + (templates ? `\n\n### Resource Templates\n${templates}` : "") + (resources ? `\n\n### Direct Resources\n${resources}` : "") @@ -402,7 +404,9 @@ The user may ask to add tools or resources that may make sense to add to an exis .getServers() .map((server) => server.name) .join(", ") || "(None running currently)" - }, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file${diffStrategy ? " or apply_diff" : ""} to make changes to the files. + }, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file${ + diffStrategy ? " or apply_diff" : "" + } to make changes to the files. However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server. diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index fada0313926..9ee659a76df 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -102,6 +102,7 @@ type GlobalStateKey = | "mcpEnabled" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "maxApiRetries" | "currentApiConfigName" | "listApiConfigMeta" | "vsCodeLmModelSelector" @@ -728,6 +729,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 ?? 0) + await this.postStateToWebview() + break case "preferredLanguage": await this.updateGlobalState("preferredLanguage", message.text) await this.postStateToWebview() @@ -1740,6 +1745,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + maxApiRetries, currentApiConfigName, listApiConfigMeta, mode, @@ -1779,6 +1785,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + maxApiRetries: maxApiRetries ?? 0, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], mode: mode ?? defaultModeSlug, @@ -1896,6 +1903,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + maxApiRetries, currentApiConfigName, listApiConfigMeta, vsCodeLmModelSelector, @@ -1960,6 +1968,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("vsCodeLmModelSelector") as Promise, @@ -2072,6 +2081,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + 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 63dc2d58e16..870c8fc168e 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -318,6 +318,7 @@ describe("ClineProvider", () => { fuzzyMatchThreshold: 1.0, mcpEnabled: true, requestDelaySeconds: 5, + maxApiRetries: 0, mode: defaultModeSlug, customModes: [], } @@ -452,6 +453,43 @@ describe("ClineProvider", () => { expect(state.requestDelaySeconds).toBe(5) }) + 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 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 () => { // Mock globalState.get to return undefined for alwaysApproveResubmit ;(mockContext.globalState.get as jest.Mock).mockReturnValue(undefined) @@ -543,6 +581,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("handles updatePrompt message correctly", async () => { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 3bcd8a0a3ad..62378a9a515 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -90,6 +90,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 57053785563..cd2b429471c 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -64,6 +64,7 @@ export interface WebviewMessage { | "refreshGlamaModels" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "maxApiRetries" | "setApiConfigPassword" | "requestVsCodeLmModels" | "mode" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 5efc698418b..628eeb8aae8 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -786,7 +786,9 @@ export const ChatRowContent = ({ padding: `2px 8px ${isExpanded ? 0 : 8}px 8px`, }}> + className={`codicon codicon-chevron-${ + isExpanded ? "down" : "right" + }`}> Command Output {isExpanded && } diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index da15b4e41f5..6033d9b9f7a 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1021,7 +1021,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie : 0.5 : 0, display: "flex", - padding: `${primaryButtonText || secondaryButtonText || isStreaming ? "10" : "0"}px 15px 0px 15px`, + padding: `${ + primaryButtonText || secondaryButtonText || isStreaming ? "10" : "0" + }px 15px 0px 15px`, }}> {primaryButtonText && !isStreaming && ( { diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index e2af538dbe4..a4ef3448bb1 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -49,6 +49,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setAlwaysApproveResubmit, requestDelaySeconds, setRequestDelaySeconds, + maxApiRetries, + setMaxApiRetries, currentApiConfigName, listApiConfigMeta, experimentalDiffStrategy, @@ -86,6 +88,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", @@ -302,6 +305,35 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { }}> Delay before retrying the request

+
+
+ setMaxApiRetries(parseInt(e.target.value))} + style={{ + flex: 1, + accentColor: "var(--vscode-button-background)", + height: "2px", + }} + aria-label="Maximum number of retry attempts (0 for no limit)" + /> + + {maxApiRetries === 0 ? "No Limit" : `${maxApiRetries} tries`} + +
+

+ Maximum number of retry attempts for failed API requests +

+
)} diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx index 4beb30dd1c4..c798b2c6ae8 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx @@ -338,3 +338,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 ea00a0c82ad..ec77f50cdb0 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -52,6 +52,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 @@ -90,6 +92,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode mcpEnabled: true, alwaysApproveResubmit: false, requestDelaySeconds: 5, + maxApiRetries: 0, currentApiConfigName: "default", listApiConfigMeta: [], mode: defaultModeSlug, @@ -238,6 +241,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode fuzzyMatchThreshold: state.fuzzyMatchThreshold, writeDelayMs: state.writeDelayMs, screenshotQuality: state.screenshotQuality, + maxApiRetries: state.maxApiRetries ?? 0, experimentalDiffStrategy: state.experimentalDiffStrategy ?? false, setApiConfiguration: (value) => setState((prevState) => ({ @@ -266,6 +270,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,