mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
Merge branch 'RooCodeInc:main' into main
This commit is contained in:
commit
74fd8b4f9e
40 changed files with 999 additions and 21 deletions
|
|
@ -61,7 +61,7 @@ export function Evals({
|
|||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
Roo Code tests each frontier model against{" "}
|
||||
<a href="https://github.com/cte/evals/" className="underline">
|
||||
<a href="https://github.com/RooCodeInc/Roo-Code-Evals" className="underline">
|
||||
a suite of hundreds of exercises
|
||||
</a>{" "}
|
||||
across 5 programming languages with varying difficulty. These results can help you find the right
|
||||
|
|
|
|||
|
|
@ -53,12 +53,18 @@ export type ProviderSettingsEntry = z.infer<typeof providerSettingsEntrySchema>
|
|||
* 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(),
|
||||
|
|
|
|||
41
packages/types/src/providers/__tests__/claude-code.spec.ts
Normal file
41
packages/types/src/providers/__tests__/claude-code.spec.ts
Normal file
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ClineEvents> {
|
|||
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<ClineEvents> {
|
|||
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<ClineEvents> {
|
|||
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"),
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -1853,6 +1855,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 +1882,7 @@ export class ClineProvider
|
|||
diffStrategy: task?.diffStrategy?.getName(),
|
||||
isSubtask: task ? !!task.parentTask : undefined,
|
||||
cloudIsAuthenticated,
|
||||
...(todos && { todos }),
|
||||
...gitInfo,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof ignore>): Promise<string[]> {
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -115,8 +115,25 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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(<ChatTextArea {...defaultProps} setInputValue={setInputValue} />)
|
||||
// Mock document.execCommand
|
||||
const mockExecCommand = vi.fn().mockReturnValue(true)
|
||||
Object.defineProperty(document, "execCommand", {
|
||||
value: mockExecCommand,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Original prompt" />,
|
||||
)
|
||||
|
||||
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(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Original prompt" />)
|
||||
|
||||
// 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(<ChatTextArea {...defaultProps} setInputValue={setInputValue} />)
|
||||
|
||||
// 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", () => {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
<ConsecutiveMistakeLimitControl
|
||||
value={
|
||||
apiConfiguration.consecutiveMistakeLimit !== undefined
|
||||
? apiConfiguration.consecutiveMistakeLimit
|
||||
: DEFAULT_CONSECUTIVE_MISTAKE_LIMIT
|
||||
}
|
||||
onChange={(value) => setApiConfigurationField("consecutiveMistakeLimit", value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<ConsecutiveMistakeLimitControlProps> = ({ 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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="block font-medium mb-1">{t("settings:providers.consecutiveMistakeLimit.label")}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[value ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT]}
|
||||
min={0}
|
||||
max={10}
|
||||
step={1}
|
||||
onValueChange={(newValue) => handleValueChange(newValue[0])}
|
||||
/>
|
||||
<span className="w-10">{Math.max(0, value ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-vscode-descriptionForeground">
|
||||
{value === 0
|
||||
? t("settings:providers.consecutiveMistakeLimit.unlimitedDescription")
|
||||
: t("settings:providers.consecutiveMistakeLimit.description", {
|
||||
value: value ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT,
|
||||
})}
|
||||
</div>
|
||||
{value === 0 && (
|
||||
<div className="text-sm text-vscode-errorForeground mt-1">
|
||||
{t("settings:providers.consecutiveMistakeLimit.warning")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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é",
|
||||
|
|
|
|||
|
|
@ -367,6 +367,12 @@
|
|||
"label": "दर सीमा",
|
||||
"description": "API अनुरोधों के बीच न्यूनतम समय।"
|
||||
},
|
||||
"consecutiveMistakeLimit": {
|
||||
"label": "त्रुटि और पुनरावृत्ति सीमा",
|
||||
"description": "'रू को समस्या हो रही है' संवाद दिखाने से पहले लगातार त्रुटियों या दोहराए गए कार्यों की संख्या",
|
||||
"unlimitedDescription": "असीमित पुनः प्रयास सक्षम (स्वतः आगे बढ़ें)। संवाद कभी नहीं दिखाई देगा।",
|
||||
"warning": "⚠️ 0 पर सेट करने से असीमित पुनः प्रयास की अनुमति मिलती है जिससे महत्वपूर्ण एपीआई उपयोग हो सकता है"
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"label": "मॉडल तर्क प्रयास",
|
||||
"high": "उच्च",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -367,6 +367,12 @@
|
|||
"label": "レート制限",
|
||||
"description": "APIリクエスト間の最小時間。"
|
||||
},
|
||||
"consecutiveMistakeLimit": {
|
||||
"label": "エラーと繰り返しの制限",
|
||||
"description": "「Rooが問題を抱えています」ダイアログを表示するまでの連続エラーまたは繰り返しアクションの数",
|
||||
"unlimitedDescription": "無制限のリトライが有効です(自動進行)。ダイアログは表示されません。",
|
||||
"warning": "⚠️ 0に設定すると無制限のリトライが可能になり、API使用量が大幅に増加する可能性があります"
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"label": "モデル推論の労力",
|
||||
"high": "高",
|
||||
|
|
|
|||
|
|
@ -367,6 +367,12 @@
|
|||
"label": "속도 제한",
|
||||
"description": "API 요청 간 최소 시간."
|
||||
},
|
||||
"consecutiveMistakeLimit": {
|
||||
"label": "오류 및 반복 제한",
|
||||
"description": "'Roo에 문제가 발생했습니다' 대화 상자를 표시하기 전의 연속 오류 또는 반복 작업 수",
|
||||
"unlimitedDescription": "무제한 재시도 활성화 (자동 진행). 대화 상자가 나타나지 않습니다.",
|
||||
"warning": "⚠️ 0으로 설정하면 무제한 재시도가 허용되어 상당한 API 사용량이 발생할 수 있습니다"
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"label": "모델 추론 노력",
|
||||
"high": "높음",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -367,6 +367,12 @@
|
|||
"label": "Лимит скорости",
|
||||
"description": "Минимальное время между запросами к API."
|
||||
},
|
||||
"consecutiveMistakeLimit": {
|
||||
"label": "Лимит ошибок и повторений",
|
||||
"description": "Количество последовательных ошибок или повторных действий перед показом диалогового окна 'У Roo возникли проблемы'",
|
||||
"unlimitedDescription": "Включены неограниченные повторные попытки (автоматическое продолжение). Диалоговое окно никогда не появится.",
|
||||
"warning": "⚠️ Установка значения 0 разрешает неограниченные повторные попытки, что может значительно увеличить использование API"
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"label": "Усилия по рассуждению модели",
|
||||
"high": "Высокие",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -367,6 +367,12 @@
|
|||
"label": "API 请求频率限制",
|
||||
"description": "设置API请求的最小间隔时间"
|
||||
},
|
||||
"consecutiveMistakeLimit": {
|
||||
"label": "错误和重复限制",
|
||||
"description": "在显示“Roo遇到问题”对话框前允许的连续错误或重复操作次数",
|
||||
"unlimitedDescription": "已启用无限重试(自动继续)。对话框将永远不会出现。",
|
||||
"warning": "⚠️ 设置为 0 允许无限重试,这可能会消耗大量 API 使用量"
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"label": "模型推理强度",
|
||||
"high": "高",
|
||||
|
|
|
|||
|
|
@ -367,6 +367,12 @@
|
|||
"label": "速率限制",
|
||||
"description": "API 請求間的最短時間"
|
||||
},
|
||||
"consecutiveMistakeLimit": {
|
||||
"label": "錯誤和重複限制",
|
||||
"description": "在顯示「Roo 遇到問題」對話方塊前允許的連續錯誤或重複操作次數",
|
||||
"unlimitedDescription": "已啟用無限重試(自動繼續)。對話方塊將永遠不會出現。",
|
||||
"warning": "⚠️ 設定為 0 允許無限重試,這可能會消耗大量 API 使用量"
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"label": "模型推理強度",
|
||||
"high": "高",
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue