From ab1756961cf4735eedb3b512464e122b15c29da4 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:26:34 -0400 Subject: [PATCH 1/7] feat: add undo functionality for enhance prompt feature (fixes #5741) (#5742) Co-authored-by: Roo Co-authored-by: Matt Rubens --- .../src/components/chat/ChatTextArea.tsx | 21 +++++- .../chat/__tests__/ChatTextArea.spec.tsx | 67 ++++++++++++++++++- webview-ui/vitest.setup.ts | 6 ++ 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index a38b4538d0..7b404ddccb 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -115,8 +115,25 @@ const ChatTextArea = forwardRef( const message = event.data if (message.type === "enhancedPrompt") { - if (message.text) { - setInputValue(message.text) + if (message.text && textAreaRef.current) { + try { + // Use execCommand to replace text while preserving undo history + if (document.execCommand) { + // Use native browser methods to preserve undo stack + const textarea = textAreaRef.current + + // Focus the textarea to ensure it's the active element + textarea.focus() + + // Select all text first + textarea.select() + document.execCommand("insertText", false, message.text) + } else { + setInputValue(message.text) + } + } catch { + setInputValue(message.text) + } } setIsEnhancingPrompt(false) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx index 75324c97f4..973420207c 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx @@ -184,10 +184,27 @@ describe("ChatTextArea", () => { }) describe("enhanced prompt response", () => { - it("should update input value when receiving enhanced prompt", () => { + it("should update input value using native browser methods when receiving enhanced prompt", () => { const setInputValue = vi.fn() - render() + // Mock document.execCommand + const mockExecCommand = vi.fn().mockReturnValue(true) + Object.defineProperty(document, "execCommand", { + value: mockExecCommand, + writable: true, + }) + + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Mock textarea methods + const mockSelect = vi.fn() + const mockFocus = vi.fn() + textarea.select = mockSelect + textarea.focus = mockFocus // Simulate receiving enhanced prompt message window.dispatchEvent( @@ -199,8 +216,54 @@ describe("ChatTextArea", () => { }), ) + // Verify native browser methods were used + expect(mockFocus).toHaveBeenCalled() + expect(mockSelect).toHaveBeenCalled() + expect(mockExecCommand).toHaveBeenCalledWith("insertText", false, "Enhanced test prompt") + }) + + it("should fallback to setInputValue when execCommand is not available", () => { + const setInputValue = vi.fn() + + // Mock document.execCommand to be undefined (not available) + Object.defineProperty(document, "execCommand", { + value: undefined, + writable: true, + }) + + render() + + // Simulate receiving enhanced prompt message + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "enhancedPrompt", + text: "Enhanced test prompt", + }, + }), + ) + + // Verify fallback to setInputValue was used expect(setInputValue).toHaveBeenCalledWith("Enhanced test prompt") }) + + it("should not crash when textarea ref is not available", () => { + const setInputValue = vi.fn() + + render() + + // Simulate receiving enhanced prompt message when textarea ref might not be ready + expect(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "enhancedPrompt", + text: "Enhanced test prompt", + }, + }), + ) + }).not.toThrow() + }) }) describe("multi-file drag and drop", () => { diff --git a/webview-ui/vitest.setup.ts b/webview-ui/vitest.setup.ts index afa37bd96d..12210f0ec2 100644 --- a/webview-ui/vitest.setup.ts +++ b/webview-ui/vitest.setup.ts @@ -1,6 +1,12 @@ import "@testing-library/jest-dom" import "@testing-library/jest-dom/vitest" +// Force React into development mode for tests +// This is needed to enable act(...) function in React Testing Library +globalThis.process = globalThis.process || {} +globalThis.process.env = globalThis.process.env || {} +globalThis.process.env.NODE_ENV = "development" + class MockResizeObserver { observe() {} unobserve() {} From d0452d0d5c1c23188664d46717620610ac5fdd7d Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 15 Jul 2025 10:47:50 -0400 Subject: [PATCH 2/7] Add telemetry for todos (#5746) --- packages/types/src/telemetry.ts | 8 ++++++++ src/core/webview/ClineProvider.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index c0e2830b27..223c39484c 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -89,6 +89,14 @@ export const taskPropertiesSchema = z.object({ modelId: z.string().optional(), diffStrategy: z.string().optional(), isSubtask: z.boolean().optional(), + todos: z + .object({ + total: z.number(), + completed: z.number(), + inProgress: z.number(), + pending: z.number(), + }) + .optional(), }) export const gitPropertiesSchema = z.object({ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8fa9ceccfa..233bd46926 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1853,6 +1853,19 @@ export class ClineProvider // Get git repository information const gitInfo = await getWorkspaceGitInfo() + // Calculate todo list statistics + const todoList = task?.todoList + let todos: { total: number; completed: number; inProgress: number; pending: number } | undefined + + if (todoList && todoList.length > 0) { + todos = { + total: todoList.length, + completed: todoList.filter((todo) => todo.status === "completed").length, + inProgress: todoList.filter((todo) => todo.status === "in_progress").length, + pending: todoList.filter((todo) => todo.status === "pending").length, + } + } + // Return all properties including git info - clients will filter as needed return { appName: packageJSON?.name ?? Package.name, @@ -1867,6 +1880,7 @@ export class ClineProvider diffStrategy: task?.diffStrategy?.getName(), isSubtask: task ? !!task.parentTask : undefined, cloudIsAuthenticated, + ...(todos && { todos }), ...gitInfo, } } From 9b6fb36d6f9aa295ae66ef17ce8be4820a40246b Mon Sep 17 00:00:00 2001 From: ChuKhaLi <15166543+ChuKhaLi@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:14:16 +0700 Subject: [PATCH 3/7] fix(litellm): handle baseurl with paths correctly (#5697) Co-authored-by: Daniel Riccio --- .../fetchers/__tests__/litellm.spec.ts | 126 ++++++++++++++++++ src/api/providers/fetchers/litellm.ts | 6 +- 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/api/providers/fetchers/__tests__/litellm.spec.ts b/src/api/providers/fetchers/__tests__/litellm.spec.ts index 07bbe9871a..f3a9d9971e 100644 --- a/src/api/providers/fetchers/__tests__/litellm.spec.ts +++ b/src/api/providers/fetchers/__tests__/litellm.spec.ts @@ -39,6 +39,132 @@ describe("getLiteLLMModels", () => { }) }) + it("handles base URLs with a path correctly", async () => { + const mockResponse = { + data: { + data: [], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + await getLiteLLMModels("test-api-key", "http://localhost:4000/litellm") + + expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/litellm/v1/model/info", { + headers: { + Authorization: "Bearer test-api-key", + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + }, + timeout: 5000, + }) + }) + + it("handles base URLs with a path and trailing slash correctly", async () => { + const mockResponse = { + data: { + data: [], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + await getLiteLLMModels("test-api-key", "http://localhost:4000/litellm/") + + expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/litellm/v1/model/info", { + headers: { + Authorization: "Bearer test-api-key", + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + }, + timeout: 5000, + }) + }) + + it("handles base URLs with double slashes correctly", async () => { + const mockResponse = { + data: { + data: [], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + await getLiteLLMModels("test-api-key", "http://localhost:4000/litellm//") + + expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/litellm/v1/model/info", { + headers: { + Authorization: "Bearer test-api-key", + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + }, + timeout: 5000, + }) + }) + + it("handles base URLs with query parameters correctly", async () => { + const mockResponse = { + data: { + data: [], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + await getLiteLLMModels("test-api-key", "http://localhost:4000/litellm?key=value") + + expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/litellm/v1/model/info?key=value", { + headers: { + Authorization: "Bearer test-api-key", + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + }, + timeout: 5000, + }) + }) + + it("handles base URLs with fragments correctly", async () => { + const mockResponse = { + data: { + data: [], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + await getLiteLLMModels("test-api-key", "http://localhost:4000/litellm#section") + + expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/litellm/v1/model/info#section", { + headers: { + Authorization: "Bearer test-api-key", + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + }, + timeout: 5000, + }) + }) + + it("handles base URLs with port and no path correctly", async () => { + const mockResponse = { + data: { + data: [], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + await getLiteLLMModels("test-api-key", "http://localhost:4000") + + expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/v1/model/info", { + headers: { + Authorization: "Bearer test-api-key", + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + }, + timeout: 5000, + }) + }) + it("successfully fetches and formats LiteLLM models", async () => { const mockResponse = { data: { diff --git a/src/api/providers/fetchers/litellm.ts b/src/api/providers/fetchers/litellm.ts index 0891527406..e4e16c30e5 100644 --- a/src/api/providers/fetchers/litellm.ts +++ b/src/api/providers/fetchers/litellm.ts @@ -24,7 +24,11 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise headers["Authorization"] = `Bearer ${apiKey}` } // Use URL constructor to properly join base URL and path - const url = new URL("/v1/model/info", baseUrl).href + // This approach handles all edge cases including paths, query params, and fragments + const urlObj = new URL(baseUrl) + // Normalize the pathname by removing trailing slashes and multiple slashes + urlObj.pathname = urlObj.pathname.replace(/\/+$/, "").replace(/\/+/g, "/") + "/v1/model/info" + const url = urlObj.href // Added timeout to prevent indefinite hanging const response = await axios.get(url, { headers, timeout: 5000 }) const models: ModelRecord = {} From 9db64de3630f993f8eb8475cc2a37f721639f995 Mon Sep 17 00:00:00 2001 From: flameboy <42799643+janaki-sasidhar@users.noreply.github.com> Date: Tue, 15 Jul 2025 23:45:33 +0530 Subject: [PATCH 4/7] Feature/vertex ai model name conversion (#5728) Co-authored-by: Claude Co-authored-by: Daniel Riccio --- .../providers/__tests__/claude-code.spec.ts | 41 +++++++++++++++++++ packages/types/src/providers/claude-code.ts | 34 +++++++++++++++ src/api/providers/claude-code.ts | 16 +++++++- 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 packages/types/src/providers/__tests__/claude-code.spec.ts diff --git a/packages/types/src/providers/__tests__/claude-code.spec.ts b/packages/types/src/providers/__tests__/claude-code.spec.ts new file mode 100644 index 0000000000..8f997068f2 --- /dev/null +++ b/packages/types/src/providers/__tests__/claude-code.spec.ts @@ -0,0 +1,41 @@ +import { describe, test, expect } from "vitest" +import { convertModelNameForVertex, getClaudeCodeModelId } from "../claude-code.js" + +describe("convertModelNameForVertex", () => { + test("should convert hyphen-date format to @date format", () => { + expect(convertModelNameForVertex("claude-sonnet-4-20250514")).toBe("claude-sonnet-4@20250514") + expect(convertModelNameForVertex("claude-opus-4-20250514")).toBe("claude-opus-4@20250514") + expect(convertModelNameForVertex("claude-3-7-sonnet-20250219")).toBe("claude-3-7-sonnet@20250219") + expect(convertModelNameForVertex("claude-3-5-sonnet-20241022")).toBe("claude-3-5-sonnet@20241022") + expect(convertModelNameForVertex("claude-3-5-haiku-20241022")).toBe("claude-3-5-haiku@20241022") + }) + + test("should not modify models without date pattern", () => { + expect(convertModelNameForVertex("some-other-model")).toBe("some-other-model") + expect(convertModelNameForVertex("claude-model")).toBe("claude-model") + expect(convertModelNameForVertex("model-with-short-date-123")).toBe("model-with-short-date-123") + }) + + test("should only convert 8-digit date patterns at the end", () => { + expect(convertModelNameForVertex("claude-20250514-sonnet")).toBe("claude-20250514-sonnet") + expect(convertModelNameForVertex("model-20250514-with-more")).toBe("model-20250514-with-more") + }) +}) + +describe("getClaudeCodeModelId", () => { + test("should return original model when useVertex is false", () => { + expect(getClaudeCodeModelId("claude-sonnet-4-20250514", false)).toBe("claude-sonnet-4-20250514") + expect(getClaudeCodeModelId("claude-opus-4-20250514", false)).toBe("claude-opus-4-20250514") + expect(getClaudeCodeModelId("claude-3-7-sonnet-20250219", false)).toBe("claude-3-7-sonnet-20250219") + }) + + test("should return converted model when useVertex is true", () => { + expect(getClaudeCodeModelId("claude-sonnet-4-20250514", true)).toBe("claude-sonnet-4@20250514") + expect(getClaudeCodeModelId("claude-opus-4-20250514", true)).toBe("claude-opus-4@20250514") + expect(getClaudeCodeModelId("claude-3-7-sonnet-20250219", true)).toBe("claude-3-7-sonnet@20250219") + }) + + test("should default to useVertex false when parameter not provided", () => { + expect(getClaudeCodeModelId("claude-sonnet-4-20250514")).toBe("claude-sonnet-4-20250514") + }) +}) diff --git a/packages/types/src/providers/claude-code.ts b/packages/types/src/providers/claude-code.ts index b623d943f8..df8b3d302a 100644 --- a/packages/types/src/providers/claude-code.ts +++ b/packages/types/src/providers/claude-code.ts @@ -1,10 +1,44 @@ import type { ModelInfo } from "../model.js" import { anthropicModels } from "./anthropic.js" +// Regex pattern to match 8-digit date at the end of model names +const VERTEX_DATE_PATTERN = /-(\d{8})$/ + +/** + * Converts Claude model names from hyphen-date format to Vertex AI's @-date format. + * + * @param modelName - The original model name (e.g., "claude-sonnet-4-20250514") + * @returns The converted model name for Vertex AI (e.g., "claude-sonnet-4@20250514") + * + * @example + * convertModelNameForVertex("claude-sonnet-4-20250514") // returns "claude-sonnet-4@20250514" + * convertModelNameForVertex("claude-model") // returns "claude-model" (no change) + */ +export function convertModelNameForVertex(modelName: string): string { + // Convert hyphen-date format to @date format for Vertex AI + return modelName.replace(VERTEX_DATE_PATTERN, "@$1") +} + // Claude Code export type ClaudeCodeModelId = keyof typeof claudeCodeModels export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-20250514" export const CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS = 8000 + +/** + * Gets the appropriate model ID based on whether Vertex AI is being used. + * + * @param baseModelId - The base Claude Code model ID + * @param useVertex - Whether to format the model ID for Vertex AI (default: false) + * @returns The model ID, potentially formatted for Vertex AI + * + * @example + * getClaudeCodeModelId("claude-sonnet-4-20250514", true) // returns "claude-sonnet-4@20250514" + * getClaudeCodeModelId("claude-sonnet-4-20250514", false) // returns "claude-sonnet-4-20250514" + */ +export function getClaudeCodeModelId(baseModelId: ClaudeCodeModelId, useVertex = false): string { + return useVertex ? convertModelNameForVertex(baseModelId) : baseModelId +} + export const claudeCodeModels = { "claude-sonnet-4-20250514": { ...anthropicModels["claude-sonnet-4-20250514"], diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index 9ce23a2842..dfafb78aab 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -1,5 +1,11 @@ import type { Anthropic } from "@anthropic-ai/sdk" -import { claudeCodeDefaultModelId, type ClaudeCodeModelId, claudeCodeModels, type ModelInfo } from "@roo-code/types" +import { + claudeCodeDefaultModelId, + type ClaudeCodeModelId, + claudeCodeModels, + type ModelInfo, + getClaudeCodeModelId, +} from "@roo-code/types" import { type ApiHandler } from ".." import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream" import { runClaudeCode } from "../../integrations/claude-code/run" @@ -20,11 +26,17 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler { // Filter out image blocks since Claude Code doesn't support them const filteredMessages = filterMessagesForClaudeCode(messages) + const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1" + const model = this.getModel() + + // Validate that the model ID is a valid ClaudeCodeModelId + const modelId = model.id in claudeCodeModels ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId + const claudeProcess = runClaudeCode({ systemPrompt, messages: filteredMessages, path: this.options.claudeCodePath, - modelId: this.getModel().id, + modelId: getClaudeCodeModelId(modelId, useVertex), maxOutputTokens: this.options.claudeCodeMaxOutputTokens, }) From b75377416a1a303b6a88afe0ec3cb935220b0a58 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Tue, 15 Jul 2025 13:00:50 -0700 Subject: [PATCH 5/7] Update evals repo link (#5758) --- apps/web-roo-code/src/app/evals/evals.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web-roo-code/src/app/evals/evals.tsx b/apps/web-roo-code/src/app/evals/evals.tsx index 5921112c46..6b619de2b8 100644 --- a/apps/web-roo-code/src/app/evals/evals.tsx +++ b/apps/web-roo-code/src/app/evals/evals.tsx @@ -61,7 +61,7 @@ export function Evals({
Roo Code tests each frontier model against{" "} - + a suite of hundreds of exercises {" "} across 5 programming languages with varying difficulty. These results can help you find the right From 5557d77a965d98a710dff1aa84dc6013b23b33a5 Mon Sep 17 00:00:00 2001 From: axb Date: Wed, 16 Jul 2025 05:34:03 +0800 Subject: [PATCH 6/7] list-files must include at least the first-level directory contents (#5303) Co-authored-by: Daniel Riccio --- .../glob/__tests__/list-files.spec.ts | 202 +++++++++++++++++- src/services/glob/list-files.ts | 103 ++++++++- 2 files changed, 297 insertions(+), 8 deletions(-) diff --git a/src/services/glob/__tests__/list-files.spec.ts b/src/services/glob/__tests__/list-files.spec.ts index 2a15424830..6c133a732a 100644 --- a/src/services/glob/__tests__/list-files.spec.ts +++ b/src/services/glob/__tests__/list-files.spec.ts @@ -1,5 +1,7 @@ -import { describe, it, expect, vi } from "vitest" +import { vi, describe, it, expect, beforeEach } from "vitest" +import * as path from "path" import { listFiles } from "../list-files" +import * as childProcess from "child_process" vi.mock("../list-files", async () => { const actual = await vi.importActual("../list-files") @@ -16,3 +18,201 @@ describe("listFiles", () => { expect(result).toEqual([[], false]) }) }) + +// Mock ripgrep to avoid filesystem dependencies +vi.mock("../../ripgrep", () => ({ + getBinPath: vi.fn().mockResolvedValue("/mock/path/to/rg"), +})) + +// Mock vscode +vi.mock("vscode", () => ({ + env: { + appRoot: "/mock/app/root", + }, +})) + +// Mock filesystem operations +vi.mock("fs", () => ({ + promises: { + access: vi.fn().mockRejectedValue(new Error("Not found")), + readFile: vi.fn().mockResolvedValue(""), + readdir: vi.fn().mockResolvedValue([]), + }, +})) + +// Import fs to set up mocks +import * as fs from "fs" + +vi.mock("child_process", () => ({ + spawn: vi.fn(), +})) + +vi.mock("../../path", () => ({ + arePathsEqual: vi.fn().mockReturnValue(false), +})) + +describe("list-files symlink support", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should include --follow flag in ripgrep arguments", async () => { + const mockSpawn = vi.mocked(childProcess.spawn) + const mockProcess = { + stdout: { + on: vi.fn((event, callback) => { + if (event === "data") { + // Simulate some output to complete the process + setTimeout(() => callback("test-file.txt\n"), 10) + } + }), + }, + stderr: { + on: vi.fn(), + }, + on: vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + if (event === "error") { + // No error simulation + } + }), + kill: vi.fn(), + } + + mockSpawn.mockReturnValue(mockProcess as any) + + // Call listFiles to trigger ripgrep execution + await listFiles("/test/dir", false, 100) + + // Verify that spawn was called with --follow flag (the critical fix) + const [rgPath, args] = mockSpawn.mock.calls[0] + expect(rgPath).toBe("/mock/path/to/rg") + expect(args).toContain("--files") + expect(args).toContain("--hidden") + expect(args).toContain("--follow") // This is the critical assertion - the fix should add this flag + + // Platform-agnostic path check - verify the last argument is the resolved path + const expectedPath = path.resolve("/test/dir") + expect(args[args.length - 1]).toBe(expectedPath) + }) + + it("should include --follow flag for recursive listings too", async () => { + const mockSpawn = vi.mocked(childProcess.spawn) + const mockProcess = { + stdout: { + on: vi.fn((event, callback) => { + if (event === "data") { + setTimeout(() => callback("test-file.txt\n"), 10) + } + }), + }, + stderr: { + on: vi.fn(), + }, + on: vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + if (event === "error") { + // No error simulation + } + }), + kill: vi.fn(), + } + + mockSpawn.mockReturnValue(mockProcess as any) + + // Call listFiles with recursive=true + await listFiles("/test/dir", true, 100) + + // Verify that spawn was called with --follow flag (the critical fix) + const [rgPath, args] = mockSpawn.mock.calls[0] + expect(rgPath).toBe("/mock/path/to/rg") + expect(args).toContain("--files") + expect(args).toContain("--hidden") + expect(args).toContain("--follow") // This should be present in recursive mode too + + // Platform-agnostic path check - verify the last argument is the resolved path + const expectedPath = path.resolve("/test/dir") + expect(args[args.length - 1]).toBe(expectedPath) + }) + + it("should ensure first-level directories are included when limit is reached", async () => { + // Mock fs.promises.readdir to simulate a directory structure + const mockReaddir = vi.mocked(fs.promises.readdir) + + // Root directory with first-level directories + mockReaddir.mockResolvedValueOnce([ + { name: "a_dir", isDirectory: () => true, isSymbolicLink: () => false, isFile: () => false } as any, + { name: "b_dir", isDirectory: () => true, isSymbolicLink: () => false, isFile: () => false } as any, + { name: "c_dir", isDirectory: () => true, isSymbolicLink: () => false, isFile: () => false } as any, + { name: "file1.txt", isDirectory: () => false, isSymbolicLink: () => false, isFile: () => true } as any, + { name: "file2.txt", isDirectory: () => false, isSymbolicLink: () => false, isFile: () => true } as any, + ]) + + // Mock ripgrep to return many files (simulating hitting the limit) + const mockSpawn = vi.mocked(childProcess.spawn) + const mockProcess = { + stdout: { + on: vi.fn((event, callback) => { + if (event === "data") { + // Return many file paths to trigger the limit + const paths = + [ + "/test/dir/a_dir/", + "/test/dir/a_dir/subdir1/", + "/test/dir/a_dir/subdir1/file1.txt", + "/test/dir/a_dir/subdir1/file2.txt", + "/test/dir/a_dir/subdir2/", + "/test/dir/a_dir/subdir2/file3.txt", + "/test/dir/a_dir/file4.txt", + "/test/dir/a_dir/file5.txt", + "/test/dir/file1.txt", + "/test/dir/file2.txt", + // Note: b_dir and c_dir are missing from ripgrep output + ].join("\n") + "\n" + setTimeout(() => callback(paths), 10) + } + }), + }, + stderr: { + on: vi.fn(), + }, + on: vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + }), + kill: vi.fn(), + } + mockSpawn.mockReturnValue(mockProcess as any) + + // Mock fs.promises.access to simulate .gitignore doesn't exist + vi.mocked(fs.promises.access).mockRejectedValue(new Error("File not found")) + + // Call listFiles with recursive=true and a small limit + const [results, limitReached] = await listFiles("/test/dir", true, 10) + + // Verify that we got results and hit the limit + expect(results.length).toBe(10) + expect(limitReached).toBe(true) + + // Count directories in results + const directories = results.filter((r) => r.endsWith("/")) + + // We should have at least the 3 first-level directories + // even if ripgrep didn't return all of them + expect(directories.length).toBeGreaterThanOrEqual(3) + + // Verify all first-level directories are included + const hasADir = results.some((r) => r.endsWith("a_dir/")) + const hasBDir = results.some((r) => r.endsWith("b_dir/")) + const hasCDir = results.some((r) => r.endsWith("c_dir/")) + + expect(hasADir).toBe(true) + expect(hasBDir).toBe(true) + expect(hasCDir).toBe(true) + }) +}) diff --git a/src/services/glob/list-files.ts b/src/services/glob/list-files.ts index 3164ed1eb0..05fa8a1d7b 100644 --- a/src/services/glob/list-files.ts +++ b/src/services/glob/list-files.ts @@ -32,15 +32,105 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb // Get ripgrep path const rgPath = await getRipgrepPath() - // Get files using ripgrep - const files = await listFilesWithRipgrep(rgPath, dirPath, recursive, limit) + if (!recursive) { + // For non-recursive, use the existing approach + const files = await listFilesWithRipgrep(rgPath, dirPath, false, limit) + const ignoreInstance = await createIgnoreInstance(dirPath) + const directories = await listFilteredDirectories(dirPath, false, ignoreInstance) + return formatAndCombineResults(files, directories, limit) + } - // Get directories with proper filtering using ignore library + // For recursive mode, use the original approach but ensure first-level directories are included + const files = await listFilesWithRipgrep(rgPath, dirPath, true, limit) const ignoreInstance = await createIgnoreInstance(dirPath) - const directories = await listFilteredDirectories(dirPath, recursive, ignoreInstance) + const directories = await listFilteredDirectories(dirPath, true, ignoreInstance) - // Combine and format the results - return formatAndCombineResults(files, directories, limit) + // Combine and check if we hit the limit + const [results, limitReached] = formatAndCombineResults(files, directories, limit) + + // If we hit the limit, ensure all first-level directories are included + if (limitReached) { + const firstLevelDirs = await getFirstLevelDirectories(dirPath, ignoreInstance) + return ensureFirstLevelDirectoriesIncluded(results, firstLevelDirs, limit) + } + + return [results, limitReached] +} + +/** + * Get only the first-level directories in a path + */ +async function getFirstLevelDirectories(dirPath: string, ignoreInstance: ReturnType): Promise { + const absolutePath = path.resolve(dirPath) + const directories: string[] = [] + + try { + const entries = await fs.promises.readdir(absolutePath, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.isDirectory() && !entry.isSymbolicLink()) { + const fullDirPath = path.join(absolutePath, entry.name) + if (shouldIncludeDirectory(entry.name, fullDirPath, dirPath, ignoreInstance)) { + const formattedPath = fullDirPath.endsWith("/") ? fullDirPath : `${fullDirPath}/` + directories.push(formattedPath) + } + } + } + } catch (err) { + console.warn(`Could not read directory ${absolutePath}: ${err}`) + } + + return directories +} + +/** + * Ensure all first-level directories are included in the results + */ +function ensureFirstLevelDirectoriesIncluded( + results: string[], + firstLevelDirs: string[], + limit: number, +): [string[], boolean] { + // Create a set of existing paths for quick lookup + const existingPaths = new Set(results) + + // Find missing first-level directories + const missingDirs = firstLevelDirs.filter((dir) => !existingPaths.has(dir)) + + if (missingDirs.length === 0) { + // All first-level directories are already included + return [results, true] + } + + // We need to make room for the missing directories + // Remove items from the end (which are likely deeper in the tree) + const itemsToRemove = Math.min(missingDirs.length, results.length) + const adjustedResults = results.slice(0, results.length - itemsToRemove) + + // Add the missing directories at the beginning (after any existing first-level dirs) + // First, separate existing results into first-level and others + const resultPaths = adjustedResults.map((r) => path.resolve(r)) + const basePath = path.resolve(firstLevelDirs[0]).split(path.sep).slice(0, -1).join(path.sep) + + const firstLevelResults: string[] = [] + const otherResults: string[] = [] + + for (let i = 0; i < adjustedResults.length; i++) { + const resolvedPath = resultPaths[i] + const relativePath = path.relative(basePath, resolvedPath) + const depth = relativePath.split(path.sep).length + + if (depth === 1) { + firstLevelResults.push(adjustedResults[i]) + } else { + otherResults.push(adjustedResults[i]) + } + } + + // Combine: existing first-level dirs + missing first-level dirs + other results + const finalResults = [...firstLevelResults, ...missingDirs, ...otherResults].slice(0, limit) + + return [finalResults, true] } /** @@ -312,7 +402,6 @@ function isDirectoryExplicitlyIgnored(dirName: string): boolean { return false } - /** * Combine file and directory results and format them properly */ From 93f88b45b6192b51cebe9c207386b2cfa4754dd4 Mon Sep 17 00:00:00 2001 From: Murilo Pires <50873657+MuriloFP@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:25:43 -0300 Subject: [PATCH 7/7] feat: Add configurable error & repetition limit with unified control (#5654) (#5752) Co-authored-by: Daniel Riccio --- packages/types/src/provider-settings.ts | 6 ++ src/core/config/ProviderSettingsManager.ts | 22 +++++++ .../__tests__/ProviderSettingsManager.spec.ts | 42 ++++++++++++ src/core/task/Task.ts | 7 +- src/core/task/__tests__/Task.spec.ts | 64 +++++++++++++++++++ src/core/tools/ToolRepetitionDetector.ts | 7 +- .../__tests__/ToolRepetitionDetector.spec.ts | 56 ++++++++++++++++ src/core/webview/ClineProvider.ts | 2 + .../src/components/settings/ApiOptions.tsx | 10 +++ .../ConsecutiveMistakeLimitControl.tsx | 50 +++++++++++++++ webview-ui/src/i18n/locales/ca/settings.json | 6 ++ webview-ui/src/i18n/locales/de/settings.json | 6 ++ webview-ui/src/i18n/locales/en/settings.json | 6 ++ webview-ui/src/i18n/locales/es/settings.json | 6 ++ webview-ui/src/i18n/locales/fr/settings.json | 6 ++ webview-ui/src/i18n/locales/hi/settings.json | 6 ++ webview-ui/src/i18n/locales/id/settings.json | 6 ++ webview-ui/src/i18n/locales/it/settings.json | 6 ++ webview-ui/src/i18n/locales/ja/settings.json | 6 ++ webview-ui/src/i18n/locales/ko/settings.json | 6 ++ webview-ui/src/i18n/locales/nl/settings.json | 6 ++ webview-ui/src/i18n/locales/pl/settings.json | 6 ++ .../src/i18n/locales/pt-BR/settings.json | 6 ++ webview-ui/src/i18n/locales/ru/settings.json | 6 ++ webview-ui/src/i18n/locales/tr/settings.json | 6 ++ webview-ui/src/i18n/locales/vi/settings.json | 6 ++ .../src/i18n/locales/zh-CN/settings.json | 6 ++ .../src/i18n/locales/zh-TW/settings.json | 6 ++ 28 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 webview-ui/src/components/settings/ConsecutiveMistakeLimitControl.tsx diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 4cf4b30972..3b53627295 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -53,12 +53,18 @@ export type ProviderSettingsEntry = z.infer * ProviderSettings */ +/** + * Default value for consecutive mistake limit + */ +export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3 + const baseProviderSettingsSchema = z.object({ includeMaxTokens: z.boolean().optional(), diffEnabled: z.boolean().optional(), fuzzyMatchThreshold: z.number().optional(), modelTemperature: z.number().nullish(), rateLimitSeconds: z.number().optional(), + consecutiveMistakeLimit: z.number().min(0).optional(), // Model reasoning. enableReasoningEffort: z.boolean().optional(), diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 32c0135d3b..7823a3040a 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -5,6 +5,7 @@ import { type ProviderSettingsEntry, providerSettingsSchema, providerSettingsSchemaDiscriminated, + DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -26,6 +27,7 @@ export const providerProfilesSchema = z.object({ rateLimitSecondsMigrated: z.boolean().optional(), diffSettingsMigrated: z.boolean().optional(), openAiHeadersMigrated: z.boolean().optional(), + consecutiveMistakeLimitMigrated: z.boolean().optional(), }) .optional(), }) @@ -48,6 +50,7 @@ export class ProviderSettingsManager { rateLimitSecondsMigrated: true, // Mark as migrated on fresh installs diffSettingsMigrated: true, // Mark as migrated on fresh installs openAiHeadersMigrated: true, // Mark as migrated on fresh installs + consecutiveMistakeLimitMigrated: true, // Mark as migrated on fresh installs }, } @@ -113,6 +116,7 @@ export class ProviderSettingsManager { rateLimitSecondsMigrated: false, diffSettingsMigrated: false, openAiHeadersMigrated: false, + consecutiveMistakeLimitMigrated: false, } // Initialize with default values isDirty = true } @@ -135,6 +139,12 @@ export class ProviderSettingsManager { isDirty = true } + if (!providerProfiles.migrations.consecutiveMistakeLimitMigrated) { + await this.migrateConsecutiveMistakeLimit(providerProfiles) + providerProfiles.migrations.consecutiveMistakeLimitMigrated = true + isDirty = true + } + if (isDirty) { await this.store(providerProfiles) } @@ -228,6 +238,18 @@ export class ProviderSettingsManager { } } + private async migrateConsecutiveMistakeLimit(providerProfiles: ProviderProfiles) { + try { + for (const [name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { + if (apiConfig.consecutiveMistakeLimit == null) { + apiConfig.consecutiveMistakeLimit = DEFAULT_CONSECUTIVE_MISTAKE_LIMIT + } + } + } catch (error) { + console.error(`[MigrateConsecutiveMistakeLimit] Failed to migrate consecutive mistake limit:`, error) + } + } + /** * List all available configs with metadata. */ diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 6c37d733c4..6d24c63101 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -66,6 +66,7 @@ describe("ProviderSettingsManager", () => { rateLimitSecondsMigrated: true, diffSettingsMigrated: true, openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, }, }), ) @@ -144,6 +145,47 @@ describe("ProviderSettingsManager", () => { expect(storedConfig.apiConfigs.existing.rateLimitSeconds).toEqual(43) }) + it("should call migrateConsecutiveMistakeLimit if it has not done so already", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { + default: { + config: {}, + id: "default", + consecutiveMistakeLimit: undefined, + }, + test: { + apiProvider: "anthropic", + consecutiveMistakeLimit: undefined, + }, + existing: { + apiProvider: "anthropic", + // this should not really be possible, unless someone has loaded a hand edited config, + // but we don't overwrite so we'll check that + consecutiveMistakeLimit: 5, + }, + }, + migrations: { + rateLimitSecondsMigrated: true, + diffSettingsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: false, + }, + }), + ) + + await providerSettingsManager.initialize() + + // Get the last call to store, which should contain the migrated config + const calls = mockSecrets.store.mock.calls + const storedConfig = JSON.parse(calls[calls.length - 1][1]) + expect(storedConfig.apiConfigs.default.consecutiveMistakeLimit).toEqual(3) + expect(storedConfig.apiConfigs.test.consecutiveMistakeLimit).toEqual(3) + expect(storedConfig.apiConfigs.existing.consecutiveMistakeLimit).toEqual(5) + expect(storedConfig.migrations.consecutiveMistakeLimitMigrated).toEqual(true) + }) + it("should throw error if secrets storage fails", async () => { mockSecrets.get.mockRejectedValue(new Error("Storage failed")) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index aa0590fedd..8a1bf1101d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -18,6 +18,7 @@ import { type ClineMessage, type ClineSay, type ToolProgressStatus, + DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, type HistoryItem, TelemetryEventName, TodoItem, @@ -216,7 +217,7 @@ export class Task extends EventEmitter { enableDiff = false, enableCheckpoints = true, fuzzyMatchThreshold = 1.0, - consecutiveMistakeLimit = 3, + consecutiveMistakeLimit = DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, task, images, historyItem, @@ -255,7 +256,7 @@ export class Task extends EventEmitter { this.browserSession = new BrowserSession(provider.context) this.diffEnabled = enableDiff this.fuzzyMatchThreshold = fuzzyMatchThreshold - this.consecutiveMistakeLimit = consecutiveMistakeLimit + this.consecutiveMistakeLimit = consecutiveMistakeLimit ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT this.providerRef = new WeakRef(provider) this.globalStoragePath = provider.context.globalStorageUri.fsPath this.diffViewProvider = new DiffViewProvider(this.cwd) @@ -1159,7 +1160,7 @@ export class Task extends EventEmitter { throw new Error(`[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`) } - if (this.consecutiveMistakeCount >= this.consecutiveMistakeLimit) { + if (this.consecutiveMistakeLimit > 0 && this.consecutiveMistakeCount >= this.consecutiveMistakeLimit) { const { response, text, images } = await this.ask( "mistake_limit_reached", t("common:errors.mistake_limit_guidance"), diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 693f72d1c7..797714cde8 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -320,6 +320,70 @@ describe("Cline", () => { expect(cline.diffStrategy).toBeDefined() }) + it("should use default consecutiveMistakeLimit when not provided", () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + expect(cline.consecutiveMistakeLimit).toBe(3) + }) + + it("should respect provided consecutiveMistakeLimit", () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + consecutiveMistakeLimit: 5, + task: "test task", + startTask: false, + }) + + expect(cline.consecutiveMistakeLimit).toBe(5) + }) + + it("should keep consecutiveMistakeLimit of 0 as 0 for unlimited", () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + consecutiveMistakeLimit: 0, + task: "test task", + startTask: false, + }) + + expect(cline.consecutiveMistakeLimit).toBe(0) + }) + + it("should pass 0 to ToolRepetitionDetector for unlimited mode", () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + consecutiveMistakeLimit: 0, + task: "test task", + startTask: false, + }) + + // The toolRepetitionDetector should be initialized with 0 for unlimited mode + expect(cline.toolRepetitionDetector).toBeDefined() + // Verify the limit remains as 0 + expect(cline.consecutiveMistakeLimit).toBe(0) + }) + + it("should pass consecutiveMistakeLimit to ToolRepetitionDetector", () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + consecutiveMistakeLimit: 5, + task: "test task", + startTask: false, + }) + + // The toolRepetitionDetector should be initialized with the same limit + expect(cline.toolRepetitionDetector).toBeDefined() + expect(cline.consecutiveMistakeLimit).toBe(5) + }) + it("should require either task or historyItem", () => { expect(() => { new Task({ provider: mockProvider, apiConfiguration: mockApiConfig }) diff --git a/src/core/tools/ToolRepetitionDetector.ts b/src/core/tools/ToolRepetitionDetector.ts index a82574ba0e..927b031e3b 100644 --- a/src/core/tools/ToolRepetitionDetector.ts +++ b/src/core/tools/ToolRepetitionDetector.ts @@ -43,8 +43,11 @@ export class ToolRepetitionDetector { this.previousToolCallJson = currentToolCallJson } - // Check if limit is reached - if (this.consecutiveIdenticalToolCallCount >= this.consecutiveIdenticalToolCallLimit) { + // Check if limit is reached (0 means unlimited) + if ( + this.consecutiveIdenticalToolCallLimit > 0 && + this.consecutiveIdenticalToolCallCount >= this.consecutiveIdenticalToolCallLimit + ) { // Reset counters to allow recovery if user guides the AI past this point this.consecutiveIdenticalToolCallCount = 0 this.previousToolCallJson = null diff --git a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts index 972d401141..42041c1a46 100644 --- a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts +++ b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts @@ -301,5 +301,61 @@ describe("ToolRepetitionDetector", () => { expect(result3.allowExecution).toBe(false) expect(result3.askUser).toBeDefined() }) + + it("should never block when limit is 0 (unlimited)", () => { + const detector = new ToolRepetitionDetector(0) + + // Try many identical calls + for (let i = 0; i < 10; i++) { + const result = detector.check(createToolUse("tool", "tool-name")) + expect(result.allowExecution).toBe(true) + expect(result.askUser).toBeUndefined() + } + }) + + it("should handle different limits correctly", () => { + // Test with limit of 5 + const detector5 = new ToolRepetitionDetector(5) + const tool = createToolUse("tool", "tool-name") + + // First 4 calls should be allowed + for (let i = 0; i < 4; i++) { + const result = detector5.check(tool) + expect(result.allowExecution).toBe(true) + expect(result.askUser).toBeUndefined() + } + + // 5th call should be blocked + const result5 = detector5.check(tool) + expect(result5.allowExecution).toBe(false) + expect(result5.askUser).toBeDefined() + expect(result5.askUser?.messageKey).toBe("mistake_limit_reached") + }) + + it("should reset counter after blocking and allow new attempts", () => { + const detector = new ToolRepetitionDetector(2) + const tool = createToolUse("tool", "tool-name") + + // First call allowed + expect(detector.check(tool).allowExecution).toBe(true) + + // Second call should block (limit is 2) + const blocked = detector.check(tool) + expect(blocked.allowExecution).toBe(false) + + // After blocking, counter should reset and allow new attempts + expect(detector.check(tool).allowExecution).toBe(true) + }) + + it("should handle negative limits as 0 (unlimited)", () => { + const detector = new ToolRepetitionDetector(-1) + + // Should behave like unlimited + for (let i = 0; i < 5; i++) { + const result = detector.check(createToolUse("tool", "tool-name")) + expect(result.allowExecution).toBe(true) + expect(result.askUser).toBeUndefined() + } + }) }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 233bd46926..107122dcb4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -553,6 +553,7 @@ export class ClineProvider enableDiff, enableCheckpoints, fuzzyMatchThreshold, + consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit, task, images, experiments, @@ -589,6 +590,7 @@ export class ClineProvider enableDiff, enableCheckpoints, fuzzyMatchThreshold, + consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit, historyItem, experiments, rootTask: historyItem.rootTask, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 5f86929043..e3f5ae7a0d 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -6,6 +6,7 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { type ProviderName, type ProviderSettings, + DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, openRouterDefaultModelId, requestyDefaultModelId, glamaDefaultModelId, @@ -64,6 +65,7 @@ import { ThinkingBudget } from "./ThinkingBudget" import { DiffSettingsControl } from "./DiffSettingsControl" import { TemperatureControl } from "./TemperatureControl" import { RateLimitSecondsControl } from "./RateLimitSecondsControl" +import { ConsecutiveMistakeLimitControl } from "./ConsecutiveMistakeLimitControl" import { BedrockCustomArn } from "./providers/BedrockCustomArn" import { buildDocLink } from "@src/utils/docLinks" @@ -547,6 +549,14 @@ const ApiOptions = ({ value={apiConfiguration.rateLimitSeconds || 0} onChange={(value) => setApiConfigurationField("rateLimitSeconds", value)} /> + setApiConfigurationField("consecutiveMistakeLimit", value)} + /> )}
diff --git a/webview-ui/src/components/settings/ConsecutiveMistakeLimitControl.tsx b/webview-ui/src/components/settings/ConsecutiveMistakeLimitControl.tsx new file mode 100644 index 0000000000..e60b2db323 --- /dev/null +++ b/webview-ui/src/components/settings/ConsecutiveMistakeLimitControl.tsx @@ -0,0 +1,50 @@ +import React, { useCallback } from "react" +import { Slider } from "@/components/ui" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { DEFAULT_CONSECUTIVE_MISTAKE_LIMIT } from "@roo-code/types" + +interface ConsecutiveMistakeLimitControlProps { + value: number + onChange: (value: number) => void +} + +export const ConsecutiveMistakeLimitControl: React.FC = ({ value, onChange }) => { + const { t } = useAppTranslation() + + const handleValueChange = useCallback( + (newValue: number) => { + // Ensure value is not negative + const validValue = Math.max(0, newValue) + onChange(validValue) + }, + [onChange], + ) + + return ( +
+ +
+ handleValueChange(newValue[0])} + /> + {Math.max(0, value ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT)} +
+
+ {value === 0 + ? t("settings:providers.consecutiveMistakeLimit.unlimitedDescription") + : t("settings:providers.consecutiveMistakeLimit.description", { + value: value ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, + })} +
+ {value === 0 && ( +
+ {t("settings:providers.consecutiveMistakeLimit.warning")} +
+ )} +
+ ) +} diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 0dd08afb29..503b8c36ed 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -367,6 +367,12 @@ "label": "Límit de freqüència", "description": "Temps mínim entre sol·licituds d'API." }, + "consecutiveMistakeLimit": { + "label": "Límit d'errors i repeticions", + "description": "Nombre d'errors consecutius o accions repetides abans de mostrar el diàleg 'En Roo està tenint problemes'", + "unlimitedDescription": "Reintents il·limitats habilitats (procediment automàtic). El diàleg no apareixerà mai.", + "warning": "⚠️ Establir a 0 permet reintents il·limitats que poden consumir un ús significatiu de l'API" + }, "reasoningEffort": { "label": "Esforç de raonament del model", "high": "Alt", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 923475d3fd..48d0d41a41 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -367,6 +367,12 @@ "label": "Ratenbegrenzung", "description": "Minimale Zeit zwischen API-Anfragen." }, + "consecutiveMistakeLimit": { + "label": "Fehler- & Wiederholungslimit", + "description": "Anzahl aufeinanderfolgender Fehler oder wiederholter Aktionen, bevor der Dialog 'Roo hat Probleme' angezeigt wird", + "unlimitedDescription": "Unbegrenzte Wiederholungen aktiviert (automatisches Fortfahren). Der Dialog wird niemals angezeigt.", + "warning": "⚠️ Das Setzen auf 0 erlaubt unbegrenzte Wiederholungen, was zu erheblichem API-Verbrauch führen kann" + }, "reasoningEffort": { "label": "Modell-Denkaufwand", "high": "Hoch", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 25428cfb16..69ca01154d 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -367,6 +367,12 @@ "label": "Rate limit", "description": "Minimum time between API requests." }, + "consecutiveMistakeLimit": { + "label": "Error & Repetition Limit", + "description": "Number of consecutive errors or repeated actions before showing 'Roo is having trouble' dialog", + "unlimitedDescription": "Unlimited retries enabled (auto-proceed). The dialog will never appear.", + "warning": "⚠️ Setting to 0 allows unlimited retries which may consume significant API usage" + }, "reasoningEffort": { "label": "Model Reasoning Effort", "high": "High", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 7f6af9bd72..684949488e 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -367,6 +367,12 @@ "label": "Límite de tasa", "description": "Tiempo mínimo entre solicitudes de API." }, + "consecutiveMistakeLimit": { + "label": "Límite de errores y repeticiones", + "description": "Número de errores consecutivos o acciones repetidas antes de mostrar el diálogo 'Roo está teniendo problemas'", + "unlimitedDescription": "Reintentos ilimitados habilitados (proceder automáticamente). El diálogo nunca aparecerá.", + "warning": "⚠️ Establecer en 0 permite reintentos ilimitados que pueden consumir un uso significativo de la API" + }, "reasoningEffort": { "label": "Esfuerzo de razonamiento del modelo", "high": "Alto", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 5986a5bf17..8080158446 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -367,6 +367,12 @@ "label": "Limite de débit", "description": "Temps minimum entre les requêtes API." }, + "consecutiveMistakeLimit": { + "label": "Limite d'erreurs et de répétitions", + "description": "Nombre d'erreurs consécutives ou d'actions répétées avant d'afficher la boîte de dialogue 'Roo a des difficultés'", + "unlimitedDescription": "Réessais illimités activés (poursuite automatique). La boîte de dialogue n'apparaîtra jamais.", + "warning": "⚠️ Mettre à 0 autorise des réessais illimités, ce qui peut consommer une utilisation importante de l'API" + }, "reasoningEffort": { "label": "Effort de raisonnement du modèle", "high": "Élevé", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 0a99064588..30a34de434 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -367,6 +367,12 @@ "label": "दर सीमा", "description": "API अनुरोधों के बीच न्यूनतम समय।" }, + "consecutiveMistakeLimit": { + "label": "त्रुटि और पुनरावृत्ति सीमा", + "description": "'रू को समस्या हो रही है' संवाद दिखाने से पहले लगातार त्रुटियों या दोहराए गए कार्यों की संख्या", + "unlimitedDescription": "असीमित पुनः प्रयास सक्षम (स्वतः आगे बढ़ें)। संवाद कभी नहीं दिखाई देगा।", + "warning": "⚠️ 0 पर सेट करने से असीमित पुनः प्रयास की अनुमति मिलती है जिससे महत्वपूर्ण एपीआई उपयोग हो सकता है" + }, "reasoningEffort": { "label": "मॉडल तर्क प्रयास", "high": "उच्च", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 9ae5dd846c..6089d5874e 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -371,6 +371,12 @@ "label": "Rate limit", "description": "Waktu minimum antara permintaan API." }, + "consecutiveMistakeLimit": { + "label": "Batas Kesalahan & Pengulangan", + "description": "Jumlah kesalahan berturut-turut atau tindakan berulang sebelum menampilkan dialog 'Roo mengalami masalah'", + "unlimitedDescription": "Percobaan ulang tak terbatas diaktifkan (lanjut otomatis). Dialog tidak akan pernah muncul.", + "warning": "⚠️ Mengatur ke 0 memungkinkan percobaan ulang tak terbatas yang dapat menghabiskan penggunaan API yang signifikan" + }, "reasoningEffort": { "label": "Upaya Reasoning Model", "high": "Tinggi", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index a2897496ec..189efe0bed 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -367,6 +367,12 @@ "label": "Limite di frequenza", "description": "Tempo minimo tra le richieste API." }, + "consecutiveMistakeLimit": { + "label": "Limite di errori e ripetizioni", + "description": "Numero di errori consecutivi o azioni ripetute prima di mostrare la finestra di dialogo 'Roo sta riscontrando problemi'", + "unlimitedDescription": "Tentativi illimitati abilitati (procedi automaticamente). La finestra di dialogo non verrà mai visualizzata.", + "warning": "⚠️ L'impostazione a 0 consente tentativi illimitati che possono consumare un notevole utilizzo dell'API" + }, "reasoningEffort": { "label": "Sforzo di ragionamento del modello", "high": "Alto", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index bda4182851..a63d0b6800 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -367,6 +367,12 @@ "label": "レート制限", "description": "APIリクエスト間の最小時間。" }, + "consecutiveMistakeLimit": { + "label": "エラーと繰り返しの制限", + "description": "「Rooが問題を抱えています」ダイアログを表示するまでの連続エラーまたは繰り返しアクションの数", + "unlimitedDescription": "無制限のリトライが有効です(自動進行)。ダイアログは表示されません。", + "warning": "⚠️ 0に設定すると無制限のリトライが可能になり、API使用量が大幅に増加する可能性があります" + }, "reasoningEffort": { "label": "モデル推論の労力", "high": "高", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 5053ed62f3..c4da7c8602 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -367,6 +367,12 @@ "label": "속도 제한", "description": "API 요청 간 최소 시간." }, + "consecutiveMistakeLimit": { + "label": "오류 및 반복 제한", + "description": "'Roo에 문제가 발생했습니다' 대화 상자를 표시하기 전의 연속 오류 또는 반복 작업 수", + "unlimitedDescription": "무제한 재시도 활성화 (자동 진행). 대화 상자가 나타나지 않습니다.", + "warning": "⚠️ 0으로 설정하면 무제한 재시도가 허용되어 상당한 API 사용량이 발생할 수 있습니다" + }, "reasoningEffort": { "label": "모델 추론 노력", "high": "높음", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index ab12e1931c..535c462fe2 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -367,6 +367,12 @@ "label": "Snelheidslimiet", "description": "Minimale tijd tussen API-verzoeken." }, + "consecutiveMistakeLimit": { + "label": "Fout- & Herhalingslimiet", + "description": "Aantal opeenvolgende fouten of herhaalde acties voordat het dialoogvenster 'Roo ondervindt problemen' wordt weergegeven", + "unlimitedDescription": "Onbeperkt aantal nieuwe pogingen ingeschakeld (automatisch doorgaan). Het dialoogvenster zal nooit verschijnen.", + "warning": "⚠️ Instellen op 0 staat onbeperkte nieuwe pogingen toe, wat aanzienlijk API-gebruik kan verbruiken" + }, "reasoningEffort": { "label": "Model redeneervermogen", "high": "Hoog", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 266c668606..f7d80cd1a6 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -367,6 +367,12 @@ "label": "Limit szybkości", "description": "Minimalny czas między żądaniami API." }, + "consecutiveMistakeLimit": { + "label": "Limit błędów i powtórzeń", + "description": "Liczba kolejnych błędów lub powtórzonych akcji przed wyświetleniem okna dialogowego 'Roo ma problemy'", + "unlimitedDescription": "Włączono nieograniczone próby (automatyczne kontynuowanie). Okno dialogowe nigdy się nie pojawi.", + "warning": "⚠️ Ustawienie na 0 pozwala na nieograniczone próby, co może zużyć znaczną ilość API" + }, "reasoningEffort": { "label": "Wysiłek rozumowania modelu", "high": "Wysoki", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index e9825ae749..a9ccd98838 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -367,6 +367,12 @@ "label": "Limite de taxa", "description": "Tempo mínimo entre requisições de API." }, + "consecutiveMistakeLimit": { + "label": "Limite de Erros e Repetições", + "description": "Número de erros consecutivos ou ações repetidas antes de exibir o diálogo 'Roo está com problemas'", + "unlimitedDescription": "Tentativas ilimitadas ativadas (prosseguimento automático). O diálogo nunca aparecerá.", + "warning": "⚠️ Definir como 0 permite tentativas ilimitadas, o que pode consumir um uso significativo da API" + }, "reasoningEffort": { "label": "Esforço de raciocínio do modelo", "high": "Alto", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 1b74b2253f..696c5ef641 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -367,6 +367,12 @@ "label": "Лимит скорости", "description": "Минимальное время между запросами к API." }, + "consecutiveMistakeLimit": { + "label": "Лимит ошибок и повторений", + "description": "Количество последовательных ошибок или повторных действий перед показом диалогового окна 'У Roo возникли проблемы'", + "unlimitedDescription": "Включены неограниченные повторные попытки (автоматическое продолжение). Диалоговое окно никогда не появится.", + "warning": "⚠️ Установка значения 0 разрешает неограниченные повторные попытки, что может значительно увеличить использование API" + }, "reasoningEffort": { "label": "Усилия по рассуждению модели", "high": "Высокие", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 49d1803ddf..f81bfd98ea 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -367,6 +367,12 @@ "label": "Hız sınırı", "description": "API istekleri arasındaki minimum süre." }, + "consecutiveMistakeLimit": { + "label": "Hata ve Tekrar Limiti", + "description": "'Roo sorun yaşıyor' iletişim kutusunu göstermeden önceki ardışık hata veya tekrarlanan eylem sayısı", + "unlimitedDescription": "Sınırsız yeniden deneme etkin (otomatik devam et). Diyalog asla görünmeyecek.", + "warning": "⚠️ 0'a ayarlamak, önemli API kullanımına neden olabilecek sınırsız yeniden denemeye izin verir" + }, "reasoningEffort": { "label": "Model Akıl Yürütme Çabası", "high": "Yüksek", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index c12b5778a2..97f24b4733 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -367,6 +367,12 @@ "label": "Giới hạn tốc độ", "description": "Thời gian tối thiểu giữa các yêu cầu API." }, + "consecutiveMistakeLimit": { + "label": "Giới hạn lỗi và lặp lại", + "description": "Số lỗi liên tiếp hoặc hành động lặp lại trước khi hiển thị hộp thoại 'Roo đang gặp sự cố'", + "unlimitedDescription": "Đã bật thử lại không giới hạn (tự động tiếp tục). Hộp thoại sẽ không bao giờ xuất hiện.", + "warning": "⚠️ Đặt thành 0 cho phép thử lại không giới hạn, điều này có thể tiêu tốn mức sử dụng API đáng kể" + }, "reasoningEffort": { "label": "Nỗ lực suy luận của mô hình", "high": "Cao", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index c75578067d..bbe85d0e84 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -367,6 +367,12 @@ "label": "API 请求频率限制", "description": "设置API请求的最小间隔时间" }, + "consecutiveMistakeLimit": { + "label": "错误和重复限制", + "description": "在显示“Roo遇到问题”对话框前允许的连续错误或重复操作次数", + "unlimitedDescription": "已启用无限重试(自动继续)。对话框将永远不会出现。", + "warning": "⚠️ 设置为 0 允许无限重试,这可能会消耗大量 API 使用量" + }, "reasoningEffort": { "label": "模型推理强度", "high": "高", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index dec9e936b2..4ca64bbc5c 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -367,6 +367,12 @@ "label": "速率限制", "description": "API 請求間的最短時間" }, + "consecutiveMistakeLimit": { + "label": "錯誤和重複限制", + "description": "在顯示「Roo 遇到問題」對話方塊前允許的連續錯誤或重複操作次數", + "unlimitedDescription": "已啟用無限重試(自動繼續)。對話方塊將永遠不會出現。", + "warning": "⚠️ 設定為 0 允許無限重試,這可能會消耗大量 API 使用量" + }, "reasoningEffort": { "label": "模型推理強度", "high": "高",