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
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/packages/types/src/providers/groq.ts b/packages/types/src/providers/groq.ts
index 49667e357e..2eac1f954a 100644
--- a/packages/types/src/providers/groq.ts
+++ b/packages/types/src/providers/groq.ts
@@ -10,13 +10,14 @@ export type GroqModelId =
| "qwen-qwq-32b"
| "qwen/qwen3-32b"
| "deepseek-r1-distill-llama-70b"
+ | "moonshotai/kimi-k2-instruct"
export const groqDefaultModelId: GroqModelId = "llama-3.3-70b-versatile" // Defaulting to Llama3 70B Versatile
export const groqModels = {
// Models based on API response: https://api.groq.com/openai/v1/models
"llama-3.1-8b-instant": {
- maxTokens: 131072,
+ maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
@@ -25,7 +26,7 @@ export const groqModels = {
description: "Meta Llama 3.1 8B Instant model, 128K context.",
},
"llama-3.3-70b-versatile": {
- maxTokens: 32768,
+ maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
@@ -52,7 +53,7 @@ export const groqModels = {
description: "Meta Llama 4 Maverick 17B Instruct model, 128K context.",
},
"mistral-saba-24b": {
- maxTokens: 32768,
+ maxTokens: 8192,
contextWindow: 32768,
supportsImages: false,
supportsPromptCache: false,
@@ -61,7 +62,7 @@ export const groqModels = {
description: "Mistral Saba 24B model, 32K context.",
},
"qwen-qwq-32b": {
- maxTokens: 131072,
+ maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
@@ -70,7 +71,7 @@ export const groqModels = {
description: "Alibaba Qwen QwQ 32B model, 128K context.",
},
"qwen/qwen3-32b": {
- maxTokens: 40960,
+ maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
@@ -79,7 +80,7 @@ export const groqModels = {
description: "Alibaba Qwen 3 32B model, 128K context.",
},
"deepseek-r1-distill-llama-70b": {
- maxTokens: 131072,
+ maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
@@ -87,4 +88,13 @@ export const groqModels = {
outputPrice: 0.99,
description: "DeepSeek R1 Distill Llama 70B model, 128K context.",
},
+ "moonshotai/kimi-k2-instruct": {
+ maxTokens: 16384,
+ contextWindow: 131072,
+ supportsImages: false,
+ supportsPromptCache: false,
+ inputPrice: 1.0,
+ outputPrice: 3.0,
+ description: "Moonshot AI Kimi K2 Instruct 1T model, 128K context.",
+ },
} as const satisfies Record
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/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,
})
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 = {}
diff --git a/src/api/transform/model-params.ts b/src/api/transform/model-params.ts
index 8b6069666c..6ed975ac5a 100644
--- a/src/api/transform/model-params.ts
+++ b/src/api/transform/model-params.ts
@@ -5,6 +5,7 @@ import {
DEFAULT_HYBRID_REASONING_MODEL_THINKING_TOKENS,
shouldUseReasoningBudget,
shouldUseReasoningEffort,
+ getModelMaxOutputTokens,
} from "../../shared/api"
import {
@@ -76,20 +77,25 @@ export function getModelParams({
reasoningEffort: customReasoningEffort,
} = settings
- let maxTokens = model.maxTokens ?? undefined
+ // Use the centralized logic for computing maxTokens
+ const maxTokens = getModelMaxOutputTokens({
+ modelId,
+ model,
+ settings,
+ format,
+ })
+
let temperature = customTemperature ?? defaultTemperature
let reasoningBudget: ModelParams["reasoningBudget"] = undefined
let reasoningEffort: ModelParams["reasoningEffort"] = undefined
if (shouldUseReasoningBudget({ model, settings })) {
- // If `customMaxTokens` is not specified use the default.
- maxTokens = customMaxTokens ?? DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS
-
// If `customMaxThinkingTokens` is not specified use the default.
reasoningBudget = customMaxThinkingTokens ?? DEFAULT_HYBRID_REASONING_MODEL_THINKING_TOKENS
// Reasoning cannot exceed 80% of the `maxTokens` value.
- if (reasoningBudget > Math.floor(maxTokens * 0.8)) {
+ // maxTokens should always be defined for reasoning budget models, but add a guard just in case
+ if (maxTokens && reasoningBudget > Math.floor(maxTokens * 0.8)) {
reasoningBudget = Math.floor(maxTokens * 0.8)
}
@@ -106,24 +112,6 @@ export function getModelParams({
reasoningEffort = customReasoningEffort ?? model.reasoningEffort
}
- // TODO: We should consolidate this logic to compute `maxTokens` with
- // `getModelMaxOutputTokens` in order to maintain a single source of truth.
-
- const isAnthropic = format === "anthropic" || (format === "openrouter" && modelId.startsWith("anthropic/"))
-
- // For "Hybrid" reasoning models, we should discard the model's actual
- // `maxTokens` value if we're not using reasoning. We do this for Anthropic
- // models only for now. Should we do this for Gemini too?
- if (model.supportsReasoningBudget && !reasoningBudget && isAnthropic) {
- maxTokens = ANTHROPIC_DEFAULT_MAX_TOKENS
- }
-
- // For Anthropic models we should always make sure a `maxTokens` value is
- // set.
- if (!maxTokens && isAnthropic) {
- maxTokens = ANTHROPIC_DEFAULT_MAX_TOKENS
- }
-
const params: BaseModelParams = { maxTokens, temperature, reasoningEffort, reasoningBudget }
if (format === "anthropic") {
diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts
index c8553a8fc6..aa0590fedd 100644
--- a/src/core/task/Task.ts
+++ b/src/core/task/Task.ts
@@ -41,6 +41,7 @@ import { ClineAskResponse } from "../../shared/WebviewMessage"
import { defaultModeSlug } from "../../shared/modes"
import { DiffStrategy } from "../../shared/tools"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
+import { getModelMaxOutputTokens } from "../../shared/api"
// services
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
@@ -1716,15 +1717,13 @@ export class Task extends EventEmitter {
const { contextTokens } = this.getTokenUsage()
if (contextTokens) {
- // Default max tokens value for thinking models when no specific
- // value is set.
- const DEFAULT_THINKING_MODEL_MAX_TOKENS = 16_384
-
const modelInfo = this.api.getModel().info
- const maxTokens = modelInfo.supportsReasoningBudget
- ? this.apiConfiguration.modelMaxTokens || DEFAULT_THINKING_MODEL_MAX_TOKENS
- : modelInfo.maxTokens
+ const maxTokens = getModelMaxOutputTokens({
+ modelId: this.api.getModel().id,
+ model: modelInfo,
+ settings: this.apiConfiguration,
+ })
const contextWindow = modelInfo.contextWindow
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,
}
}
diff --git a/src/package.json b/src/package.json
index 9d87b6b01f..7b3c6a26cc 100644
--- a/src/package.json
+++ b/src/package.json
@@ -3,7 +3,7 @@
"displayName": "%extension.displayName%",
"description": "%extension.description%",
"publisher": "RooVeterinaryInc",
- "version": "3.23.9",
+ "version": "3.23.12",
"icon": "assets/icons/icon.png",
"galleryBanner": {
"color": "#617A91",
diff --git a/src/services/code-index/__tests__/config-manager.spec.ts b/src/services/code-index/__tests__/config-manager.spec.ts
index 6d0e59e827..2d6e704d76 100644
--- a/src/services/code-index/__tests__/config-manager.spec.ts
+++ b/src/services/code-index/__tests__/config-manager.spec.ts
@@ -8,6 +8,17 @@ import { PreviousConfigSnapshot } from "../interfaces/config"
// Mock ContextProxy
vi.mock("../../../core/config/ContextProxy")
+// Mock embeddingModels module
+vi.mock("../../../shared/embeddingModels")
+
+// Import mocked functions
+import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "../../../shared/embeddingModels"
+
+// Type the mocked functions
+const mockedGetDefaultModelId = vi.mocked(getDefaultModelId)
+const mockedGetModelDimension = vi.mocked(getModelDimension)
+const mockedGetModelScoreThreshold = vi.mocked(getModelScoreThreshold)
+
describe("CodeIndexConfigManager", () => {
let mockContextProxy: any
let configManager: CodeIndexConfigManager
@@ -339,6 +350,14 @@ describe("CodeIndexConfigManager", () => {
})
it("should NOT require restart when models have same dimensions", async () => {
+ // Mock both models to have same dimension
+ mockedGetModelDimension.mockImplementation((provider, modelId) => {
+ if (modelId === "text-embedding-3-small" || modelId === "text-embedding-ada-002") {
+ return 1536
+ }
+ return undefined
+ })
+
// Initial state with text-embedding-3-small (1536D)
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
@@ -794,6 +813,14 @@ describe("CodeIndexConfigManager", () => {
})
it("should fall back to model-specific threshold when user setting is undefined", async () => {
+ // Mock the model score threshold
+ mockedGetModelScoreThreshold.mockImplementation((provider, modelId) => {
+ if (provider === "ollama" && modelId === "nomic-embed-code") {
+ return 0.15
+ }
+ return undefined
+ })
+
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
@@ -840,6 +867,14 @@ describe("CodeIndexConfigManager", () => {
})
it("should use model-specific threshold with openai-compatible provider", async () => {
+ // Mock the model score threshold
+ mockedGetModelScoreThreshold.mockImplementation((provider, modelId) => {
+ if (provider === "openai-compatible" && modelId === "nomic-embed-code") {
+ return 0.15
+ }
+ return undefined
+ })
+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
if (key === "codebaseIndexConfig") {
return {
@@ -882,6 +917,14 @@ describe("CodeIndexConfigManager", () => {
})
it("should handle priority correctly: user > model > default", async () => {
+ // Mock the model score threshold
+ mockedGetModelScoreThreshold.mockImplementation((provider, modelId) => {
+ if (provider === "ollama" && modelId === "nomic-embed-code") {
+ return 0.15
+ }
+ return undefined
+ })
+
// Test 1: User setting takes precedence
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
@@ -1501,6 +1544,13 @@ describe("CodeIndexConfigManager", () => {
})
describe("loadConfiguration", () => {
+ beforeEach(() => {
+ // Set default mock behaviors
+ mockedGetDefaultModelId.mockReturnValue("text-embedding-3-small")
+ mockedGetModelDimension.mockReturnValue(undefined)
+ mockedGetModelScoreThreshold.mockReturnValue(undefined)
+ })
+
it("should load configuration and return proper structure", async () => {
const mockConfigValues = {
codebaseIndexEnabled: true,
@@ -1634,5 +1684,131 @@ describe("CodeIndexConfigManager", () => {
configManager = new CodeIndexConfigManager(mockContextProxy)
expect(configManager.isConfigured()).toBe(false)
})
+
+ describe("currentModelDimension", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("should return model's built-in dimension when available", async () => {
+ // Mock getModelDimension to return a built-in dimension
+ mockedGetModelDimension.mockReturnValue(1536)
+
+ mockContextProxy.getGlobalState.mockReturnValue({
+ codebaseIndexEnabled: true,
+ codebaseIndexEmbedderProvider: "openai",
+ codebaseIndexEmbedderModelId: "text-embedding-3-small",
+ codebaseIndexEmbedderModelDimension: 2048, // Custom dimension should be ignored
+ codebaseIndexQdrantUrl: "http://localhost:6333",
+ })
+ mockContextProxy.getSecret.mockImplementation((key: string) => {
+ if (key === "codeIndexOpenAiKey") return "test-key"
+ return undefined
+ })
+
+ configManager = new CodeIndexConfigManager(mockContextProxy)
+ await configManager.loadConfiguration()
+
+ // Should return model's built-in dimension, not custom
+ expect(configManager.currentModelDimension).toBe(1536)
+ expect(mockedGetModelDimension).toHaveBeenCalledWith("openai", "text-embedding-3-small")
+ })
+
+ it("should use custom dimension only when model has no built-in dimension", async () => {
+ // Mock getModelDimension to return undefined (no built-in dimension)
+ mockedGetModelDimension.mockReturnValue(undefined)
+
+ mockContextProxy.getGlobalState.mockReturnValue({
+ codebaseIndexEnabled: true,
+ codebaseIndexEmbedderProvider: "openai-compatible",
+ codebaseIndexEmbedderModelId: "custom-model",
+ codebaseIndexEmbedderModelDimension: 2048, // Custom dimension should be used
+ codebaseIndexQdrantUrl: "http://localhost:6333",
+ })
+ mockContextProxy.getSecret.mockImplementation((key: string) => {
+ if (key === "codebaseIndexOpenAiCompatibleApiKey") return "test-key"
+ return undefined
+ })
+
+ configManager = new CodeIndexConfigManager(mockContextProxy)
+ await configManager.loadConfiguration()
+
+ // Should use custom dimension as fallback
+ expect(configManager.currentModelDimension).toBe(2048)
+ expect(mockedGetModelDimension).toHaveBeenCalledWith("openai-compatible", "custom-model")
+ })
+
+ it("should return undefined when neither model dimension nor custom dimension is available", async () => {
+ // Mock getModelDimension to return undefined
+ mockedGetModelDimension.mockReturnValue(undefined)
+
+ mockContextProxy.getGlobalState.mockReturnValue({
+ codebaseIndexEnabled: true,
+ codebaseIndexEmbedderProvider: "openai-compatible",
+ codebaseIndexEmbedderModelId: "unknown-model",
+ // No custom dimension set
+ codebaseIndexQdrantUrl: "http://localhost:6333",
+ })
+ mockContextProxy.getSecret.mockImplementation((key: string) => {
+ if (key === "codebaseIndexOpenAiCompatibleApiKey") return "test-key"
+ return undefined
+ })
+
+ configManager = new CodeIndexConfigManager(mockContextProxy)
+ await configManager.loadConfiguration()
+
+ // Should return undefined
+ expect(configManager.currentModelDimension).toBe(undefined)
+ expect(mockedGetModelDimension).toHaveBeenCalledWith("openai-compatible", "unknown-model")
+ })
+
+ it("should use default model ID when modelId is not specified", async () => {
+ // Mock getDefaultModelId and getModelDimension
+ mockedGetDefaultModelId.mockReturnValue("text-embedding-3-small")
+ mockedGetModelDimension.mockReturnValue(1536)
+
+ mockContextProxy.getGlobalState.mockReturnValue({
+ codebaseIndexEnabled: true,
+ codebaseIndexEmbedderProvider: "openai",
+ // No modelId specified
+ codebaseIndexQdrantUrl: "http://localhost:6333",
+ })
+ mockContextProxy.getSecret.mockImplementation((key: string) => {
+ if (key === "codeIndexOpenAiKey") return "test-key"
+ return undefined
+ })
+
+ configManager = new CodeIndexConfigManager(mockContextProxy)
+ await configManager.loadConfiguration()
+
+ // Should use default model ID
+ expect(configManager.currentModelDimension).toBe(1536)
+ expect(mockedGetDefaultModelId).toHaveBeenCalledWith("openai")
+ expect(mockedGetModelDimension).toHaveBeenCalledWith("openai", "text-embedding-3-small")
+ })
+
+ it("should ignore invalid custom dimension (0 or negative)", async () => {
+ // Mock getModelDimension to return undefined
+ mockedGetModelDimension.mockReturnValue(undefined)
+
+ mockContextProxy.getGlobalState.mockReturnValue({
+ codebaseIndexEnabled: true,
+ codebaseIndexEmbedderProvider: "openai-compatible",
+ codebaseIndexEmbedderModelId: "custom-model",
+ codebaseIndexEmbedderModelDimension: 0, // Invalid dimension
+ codebaseIndexQdrantUrl: "http://localhost:6333",
+ })
+ mockContextProxy.getSecret.mockImplementation((key: string) => {
+ if (key === "codebaseIndexOpenAiCompatibleApiKey") return "test-key"
+ return undefined
+ })
+
+ configManager = new CodeIndexConfigManager(mockContextProxy)
+ await configManager.loadConfiguration()
+
+ // Should return undefined since custom dimension is invalid
+ expect(configManager.currentModelDimension).toBe(undefined)
+ })
+ })
})
})
diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts
index 373b0e3e82..1d8f7ba478 100644
--- a/src/services/code-index/__tests__/service-factory.spec.ts
+++ b/src/services/code-index/__tests__/service-factory.spec.ts
@@ -420,10 +420,42 @@ describe("CodeIndexServiceFactory", () => {
)
})
- it("should prioritize manual modelDimension over getModelDimension for OpenAI Compatible provider", () => {
+ it("should prioritize getModelDimension over manual modelDimension for OpenAI Compatible provider", () => {
// Arrange
const testModelId = "custom-model"
const manualDimension = 1024
+ const modelDimension = 768
+ const testConfig = {
+ embedderProvider: "openai-compatible",
+ modelId: testModelId,
+ modelDimension: manualDimension, // This should be ignored when model has built-in dimension
+ openAiCompatibleOptions: {
+ baseUrl: "https://api.example.com/v1",
+ apiKey: "test-api-key",
+ },
+ qdrantUrl: "http://localhost:6333",
+ qdrantApiKey: "test-key",
+ }
+ mockConfigManager.getConfig.mockReturnValue(testConfig as any)
+ mockGetModelDimension.mockReturnValue(modelDimension) // This should be used
+
+ // Act
+ factory.createVectorStore()
+
+ // Assert
+ expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId)
+ expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
+ "/test/workspace",
+ "http://localhost:6333",
+ modelDimension, // Should use model's built-in dimension, not manual
+ "test-key",
+ )
+ })
+
+ it("should use manual modelDimension only when model has no built-in dimension", () => {
+ // Arrange
+ const testModelId = "unknown-model"
+ const manualDimension = 1024
const testConfig = {
embedderProvider: "openai-compatible",
modelId: testModelId,
@@ -436,17 +468,17 @@ describe("CodeIndexServiceFactory", () => {
qdrantApiKey: "test-key",
}
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
- mockGetModelDimension.mockReturnValue(768) // This should be ignored
+ mockGetModelDimension.mockReturnValue(undefined) // Model has no built-in dimension
// Act
factory.createVectorStore()
// Assert
- expect(mockGetModelDimension).not.toHaveBeenCalled()
+ expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId)
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
"/test/workspace",
"http://localhost:6333",
- manualDimension,
+ manualDimension, // Should use manual dimension as fallback
"test-key",
)
})
diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts
index f022aec780..9958f456c3 100644
--- a/src/services/code-index/config-manager.ts
+++ b/src/services/code-index/config-manager.ts
@@ -398,10 +398,19 @@ export class CodeIndexConfigManager {
/**
* Gets the current model dimension being used for embeddings.
- * Returns the explicitly configured dimension or undefined if not set.
+ * Returns the model's built-in dimension if available, otherwise falls back to custom dimension.
*/
public get currentModelDimension(): number | undefined {
- return this.modelDimension
+ // First try to get the model-specific dimension
+ const modelId = this.modelId ?? getDefaultModelId(this.embedderProvider)
+ const modelDimension = getModelDimension(this.embedderProvider, modelId)
+
+ // Only use custom dimension if model doesn't have a built-in dimension
+ if (!modelDimension && this.modelDimension && this.modelDimension > 0) {
+ return this.modelDimension
+ }
+
+ return modelDimension
}
/**
diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts
index ec8b1e7ade..b7951db7ac 100644
--- a/src/services/code-index/service-factory.ts
+++ b/src/services/code-index/service-factory.ts
@@ -108,12 +108,12 @@ export class CodeIndexServiceFactory {
let vectorSize: number | undefined
- // First check if a manual dimension is provided (works for all providers)
- if (config.modelDimension && config.modelDimension > 0) {
+ // First try to get the model-specific dimension from profiles
+ vectorSize = getModelDimension(provider, modelId)
+
+ // Only use manual dimension if model doesn't have a built-in dimension
+ if (!vectorSize && config.modelDimension && config.modelDimension > 0) {
vectorSize = config.modelDimension
- } else {
- // Fall back to model-specific dimension from profiles
- vectorSize = getModelDimension(provider, modelId)
}
if (vectorSize === undefined || vectorSize <= 0) {
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
*/
diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts
index a13e823a90..08d4bdf3bb 100644
--- a/src/shared/__tests__/api.spec.ts
+++ b/src/shared/__tests__/api.spec.ts
@@ -76,7 +76,7 @@ describe("getModelMaxOutputTokens", () => {
expect(result).toBe(32000)
})
- test("should return 20% of context window when maxTokens is undefined", () => {
+ test("should return default of 8192 when maxTokens is undefined", () => {
const modelWithoutMaxTokens: ModelInfo = {
contextWindow: 100000,
supportsPromptCache: true,
@@ -88,7 +88,7 @@ describe("getModelMaxOutputTokens", () => {
settings: {},
})
- expect(result).toBe(20000) // 20% of 100000
+ expect(result).toBe(8192)
})
test("should return ANTHROPIC_DEFAULT_MAX_TOKENS for Anthropic models that support reasoning budget but aren't using it", () => {
diff --git a/src/shared/api.ts b/src/shared/api.ts
index a6199c81aa..8cbfc72133 100644
--- a/src/shared/api.ts
+++ b/src/shared/api.ts
@@ -58,14 +58,15 @@ export const getModelMaxOutputTokens = ({
modelId,
model,
settings,
+ format,
}: {
modelId: string
model: ModelInfo
settings?: ProviderSettings
+ format?: "anthropic" | "openai" | "gemini" | "openrouter"
}): number | undefined => {
// Check for Claude Code specific max output tokens setting
if (settings?.apiProvider === "claude-code") {
- // Return the configured value or default to CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS
return settings.claudeCodeMaxOutputTokens || CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS
}
@@ -73,18 +74,33 @@ export const getModelMaxOutputTokens = ({
return settings?.modelMaxTokens || DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS
}
- const isAnthropicModel = modelId.includes("claude")
+ const isAnthropicContext =
+ modelId.includes("claude") ||
+ format === "anthropic" ||
+ (format === "openrouter" && modelId.startsWith("anthropic/"))
- // For "Hybrid" reasoning models, we should discard the model's actual
- // `maxTokens` value if we're not using reasoning. We do this for Anthropic
- // models only for now. Should we do this for Gemini too?
- if (model.supportsReasoningBudget && isAnthropicModel) {
+ // For "Hybrid" reasoning models, discard the model's actual maxTokens for Anthropic contexts
+ if (model.supportsReasoningBudget && isAnthropicContext) {
return ANTHROPIC_DEFAULT_MAX_TOKENS
}
- // If maxTokens is 0 or undefined, fall back to 20% of context window
- // This matches the sliding window logic
- return model.maxTokens || Math.ceil(model.contextWindow * 0.2)
+ // For Anthropic contexts, always ensure a maxTokens value is set
+ if (isAnthropicContext && (!model.maxTokens || model.maxTokens === 0)) {
+ return ANTHROPIC_DEFAULT_MAX_TOKENS
+ }
+
+ // If model has explicit maxTokens and it's not the full context window, use it
+ if (model.maxTokens && model.maxTokens !== model.contextWindow) {
+ return model.maxTokens
+ }
+
+ // For non-Anthropic formats without explicit maxTokens, return undefined
+ if (format) {
+ return undefined
+ }
+
+ // Default fallback
+ return ANTHROPIC_DEFAULT_MAX_TOKENS
}
// GetModelsOptions
diff --git a/webview-ui/src/__tests__/ContextWindowProgressLogic.spec.ts b/webview-ui/src/__tests__/ContextWindowProgressLogic.spec.ts
index 39c6cd40d5..0f9314218c 100644
--- a/webview-ui/src/__tests__/ContextWindowProgressLogic.spec.ts
+++ b/webview-ui/src/__tests__/ContextWindowProgressLogic.spec.ts
@@ -7,41 +7,41 @@ export {} // This makes the file a proper TypeScript module
describe("ContextWindowProgress Logic", () => {
// Using the shared utility function from model-utils.ts instead of reimplementing it
- test("calculates correct token distribution with default 20% reservation", () => {
- const contextWindow = 4000
+ test("calculates correct token distribution with default 8192 reservation", () => {
+ const contextWindow = 10000
const contextTokens = 1000
const result = calculateTokenDistribution(contextWindow, contextTokens)
// Expected calculations:
- // reservedForOutput = 0.2 * 4000 = 800
- // availableSize = 4000 - 1000 - 800 = 2200
- // total = 1000 + 800 + 2200 = 4000
- expect(result.reservedForOutput).toBe(800)
- expect(result.availableSize).toBe(2200)
+ // reservedForOutput = 8192 (ANTHROPIC_DEFAULT_MAX_TOKENS)
+ // availableSize = 10000 - 1000 - 8192 = 808
+ // total = 1000 + 8192 + 808 = 10000
+ expect(result.reservedForOutput).toBe(8192)
+ expect(result.availableSize).toBe(808)
// Check percentages
- expect(result.currentPercent).toBeCloseTo(25) // 1000/4000 * 100 = 25%
- expect(result.reservedPercent).toBeCloseTo(20) // 800/4000 * 100 = 20%
- expect(result.availablePercent).toBeCloseTo(55) // 2200/4000 * 100 = 55%
+ expect(result.currentPercent).toBeCloseTo(10) // 1000/10000 * 100 = 10%
+ expect(result.reservedPercent).toBeCloseTo(81.92) // 8192/10000 * 100 = 81.92%
+ expect(result.availablePercent).toBeCloseTo(8.08) // 808/10000 * 100 = 8.08%
// Verify percentages sum to 100%
expect(result.currentPercent + result.reservedPercent + result.availablePercent).toBeCloseTo(100)
})
test("uses provided maxTokens when available instead of default calculation", () => {
- const contextWindow = 4000
+ const contextWindow = 10000
const contextTokens = 1000
- // First calculate with default 20% reservation (no maxTokens provided)
+ // First calculate with default 8192 reservation (no maxTokens provided)
const defaultResult = calculateTokenDistribution(contextWindow, contextTokens)
// Then calculate with custom maxTokens value
- const customMaxTokens = 1500 // Custom maxTokens instead of default 20%
+ const customMaxTokens = 1500 // Custom maxTokens instead of default 8192
const customResult = calculateTokenDistribution(contextWindow, contextTokens, customMaxTokens)
- // VERIFY MAXTOKEN PROP EFFECT: Custom maxTokens should be used directly instead of 20% calculation
- const defaultReserved = Math.ceil(contextWindow * 0.2) // 800 tokens (20% of 4000)
+ // VERIFY MAXTOKEN PROP EFFECT: Custom maxTokens should be used directly instead of 8192 calculation
+ const defaultReserved = 8192 // ANTHROPIC_DEFAULT_MAX_TOKENS
expect(defaultResult.reservedForOutput).toBe(defaultReserved)
expect(customResult.reservedForOutput).toBe(customMaxTokens) // Should use exact provided value
@@ -51,13 +51,13 @@ describe("ContextWindowProgress Logic", () => {
expect(defaultTooltip).not.toBe(customTooltip)
// Verify the effect on available space
- expect(customResult.availableSize).toBe(4000 - 1000 - 1500) // 1500 tokens available
- expect(defaultResult.availableSize).toBe(4000 - 1000 - 800) // 2200 tokens available
+ expect(customResult.availableSize).toBe(10000 - 1000 - 1500) // 7500 tokens available
+ expect(defaultResult.availableSize).toBe(10000 - 1000 - 8192) // 808 tokens available
// Verify the effect on percentages
- // With custom maxTokens (1500), the reserved percentage should be higher
- expect(defaultResult.reservedPercent).toBeCloseTo(20) // 800/4000 * 100 = 20%
- expect(customResult.reservedPercent).toBeCloseTo(37.5) // 1500/4000 * 100 = 37.5%
+ // With custom maxTokens (1500), the reserved percentage should be lower than default
+ expect(defaultResult.reservedPercent).toBeCloseTo(81.92) // 8192/10000 * 100 = 81.92%
+ expect(customResult.reservedPercent).toBeCloseTo(15) // 1500/10000 * 100 = 15%
// Verify percentages still sum to 100%
expect(customResult.currentPercent + customResult.reservedPercent + customResult.availablePercent).toBeCloseTo(
@@ -66,19 +66,19 @@ describe("ContextWindowProgress Logic", () => {
})
test("handles negative input values", () => {
- const contextWindow = 4000
+ const contextWindow = 10000
const contextTokens = -500 // Negative tokens should be handled gracefully
const result = calculateTokenDistribution(contextWindow, contextTokens)
// Expected calculations:
// safeContextTokens = Math.max(0, -500) = 0
- // reservedForOutput = 0.2 * 4000 = 800
- // availableSize = 4000 - 0 - 800 = 3200
- // total = 0 + 800 + 3200 = 4000
- expect(result.currentPercent).toBeCloseTo(0) // 0/4000 * 100 = 0%
- expect(result.reservedPercent).toBeCloseTo(20) // 800/4000 * 100 = 20%
- expect(result.availablePercent).toBeCloseTo(80) // 3200/4000 * 100 = 80%
+ // reservedForOutput = 8192 (ANTHROPIC_DEFAULT_MAX_TOKENS)
+ // availableSize = 10000 - 0 - 8192 = 1808
+ // total = 0 + 8192 + 1808 = 10000
+ expect(result.currentPercent).toBeCloseTo(0) // 0/10000 * 100 = 0%
+ expect(result.reservedPercent).toBeCloseTo(81.92) // 8192/10000 * 100 = 81.92%
+ expect(result.availablePercent).toBeCloseTo(18.08) // 1808/10000 * 100 = 18.08%
})
test("handles zero context window gracefully", () => {
@@ -87,9 +87,9 @@ describe("ContextWindowProgress Logic", () => {
const result = calculateTokenDistribution(contextWindow, contextTokens)
- // With zero context window, everything should be zero
- expect(result.reservedForOutput).toBe(0)
- expect(result.availableSize).toBe(0)
+ // With zero context window, the function uses ANTHROPIC_DEFAULT_MAX_TOKENS but available size becomes 0
+ expect(result.reservedForOutput).toBe(8192) // ANTHROPIC_DEFAULT_MAX_TOKENS
+ expect(result.availableSize).toBe(0) // max(0, 0 - 1000 - 8192) = 0
// The percentages maintain total of 100% even with zero context window
// due to how the division handles this edge case
@@ -98,20 +98,20 @@ describe("ContextWindowProgress Logic", () => {
})
test("handles case where tokens exceed context window", () => {
- const contextWindow = 4000
- const contextTokens = 5000 // More tokens than the window size
+ const contextWindow = 10000
+ const contextTokens = 12000 // More tokens than the window size
const result = calculateTokenDistribution(contextWindow, contextTokens)
// Expected calculations:
- // reservedForOutput = 0.2 * 4000 = 800
- // availableSize = Math.max(0, 4000 - 5000 - 800) = 0
- expect(result.reservedForOutput).toBe(800)
+ // reservedForOutput = 8192 (ANTHROPIC_DEFAULT_MAX_TOKENS)
+ // availableSize = Math.max(0, 10000 - 12000 - 8192) = 0
+ expect(result.reservedForOutput).toBe(8192)
expect(result.availableSize).toBe(0)
- // Percentages should be calculated based on total (5000 + 800 + 0 = 5800)
- expect(result.currentPercent).toBeCloseTo((5000 / 5800) * 100)
- expect(result.reservedPercent).toBeCloseTo((800 / 5800) * 100)
+ // Percentages should be calculated based on total (12000 + 8192 + 0 = 20192)
+ expect(result.currentPercent).toBeCloseTo((12000 / 20192) * 100)
+ expect(result.reservedPercent).toBeCloseTo((8192 / 20192) * 100)
expect(result.availablePercent).toBeCloseTo(0)
// Verify percentages sum to 100%
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/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx
index fa4d7ec82a..0f0b056f75 100644
--- a/webview-ui/src/components/chat/ChatView.tsx
+++ b/webview-ui/src/components/chat/ChatView.tsx
@@ -79,7 +79,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction {
+ const allModes = getAllModes(customModes)
+ const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
+ const previousModeIndex = (currentModeIndex - 1 + allModes.length) % allModes.length
+ // Update local state and notify extension to sync mode change
+ switchToMode(allModes[previousModeIndex].slug)
+ }, [mode, customModes, switchToMode])
+
// Add keyboard event handler
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
- // Check for Command + . (period)
- if ((event.metaKey || event.ctrlKey) && event.key === ".") {
+ // Check for Command/Ctrl + Period (with or without Shift)
+ // Using event.code for better cross-platform compatibility
+ if ((event.metaKey || event.ctrlKey) && event.code === "Period") {
event.preventDefault() // Prevent default browser behavior
- switchToNextMode()
+
+ if (event.shiftKey) {
+ // Shift + Period = Previous mode
+ switchToPreviousMode()
+ } else {
+ // Just Period = Next mode
+ switchToNextMode()
+ }
}
},
- [switchToNextMode],
+ [switchToNextMode, switchToPreviousMode],
)
// Add event listener
diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx
index 84703bcae2..b5742cc623 100644
--- a/webview-ui/src/components/chat/CodeIndexPopover.tsx
+++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx
@@ -643,7 +643,7 @@ export const CodeIndexPopover: React.FC = ({
className={cn("w-full", {
"border-red-500": formErrors.codebaseIndexEmbedderModelId,
})}>
-
+
{t("settings:codeIndex.selectModel")}
{getAvailableModels().map((modelId) => {
@@ -652,7 +652,7 @@ export const CodeIndexPopover: React.FC = ({
currentSettings.codebaseIndexEmbedderProvider
]?.[modelId]
return (
-
+
{modelId}{" "}
{model
? t("settings:codeIndex.modelDimensions", {
@@ -717,7 +717,7 @@ export const CodeIndexPopover: React.FC = ({
className={cn("w-full", {
"border-red-500": formErrors.codebaseIndexEmbedderModelId,
})}>
-
+
{t("settings:codeIndex.selectModel")}
{getAvailableModels().map((modelId) => {
@@ -726,7 +726,7 @@ export const CodeIndexPopover: React.FC = ({
currentSettings.codebaseIndexEmbedderProvider
]?.[modelId]
return (
-
+
{modelId}{" "}
{model
? t("settings:codeIndex.modelDimensions", {
@@ -890,7 +890,7 @@ export const CodeIndexPopover: React.FC = ({
className={cn("w-full", {
"border-red-500": formErrors.codebaseIndexEmbedderModelId,
})}>
-
+
{t("settings:codeIndex.selectModel")}
{getAvailableModels().map((modelId) => {
@@ -899,7 +899,7 @@ export const CodeIndexPopover: React.FC = ({
currentSettings.codebaseIndexEmbedderProvider
]?.[modelId]
return (
-
+
{modelId}{" "}
{model
? t("settings:codeIndex.modelDimensions", {
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/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json
index ade415a0a0..bcc41d2052 100644
--- a/webview-ui/src/i18n/locales/ca/chat.json
+++ b/webview-ui/src/i18n/locales/ca/chat.json
@@ -123,6 +123,7 @@
"separator": "Separador",
"edit": "Edita...",
"forNextMode": "per al següent mode",
+ "forPreviousMode": "per al mode anterior",
"error": "Error",
"diffError": {
"title": "Edició fallida"
diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json
index 13f0f64e45..2c85048bf0 100644
--- a/webview-ui/src/i18n/locales/de/chat.json
+++ b/webview-ui/src/i18n/locales/de/chat.json
@@ -123,6 +123,7 @@
"separator": "Trennlinie",
"edit": "Bearbeiten...",
"forNextMode": "für nächsten Modus",
+ "forPreviousMode": "für vorherigen Modus",
"error": "Fehler",
"diffError": {
"title": "Bearbeitung fehlgeschlagen"
diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json
index ef40ba854b..a5c8bd8337 100644
--- a/webview-ui/src/i18n/locales/en/chat.json
+++ b/webview-ui/src/i18n/locales/en/chat.json
@@ -131,6 +131,7 @@
"separator": "Separator",
"edit": "Edit...",
"forNextMode": "for next mode",
+ "forPreviousMode": "for previous mode",
"apiRequest": {
"title": "API Request",
"failed": "API Request Failed",
diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json
index 200939d34b..711fe7712b 100644
--- a/webview-ui/src/i18n/locales/es/chat.json
+++ b/webview-ui/src/i18n/locales/es/chat.json
@@ -123,6 +123,7 @@
"separator": "Separador",
"edit": "Editar...",
"forNextMode": "para el siguiente modo",
+ "forPreviousMode": "para el modo anterior",
"error": "Error",
"diffError": {
"title": "Edición fallida"
diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json
index 2858400e78..f3832174e6 100644
--- a/webview-ui/src/i18n/locales/fr/chat.json
+++ b/webview-ui/src/i18n/locales/fr/chat.json
@@ -123,6 +123,7 @@
"separator": "Séparateur",
"edit": "Éditer...",
"forNextMode": "pour le prochain mode",
+ "forPreviousMode": "pour le mode précédent",
"error": "Erreur",
"diffError": {
"title": "Modification échouée"
diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json
index c2db235a6b..34e80597a8 100644
--- a/webview-ui/src/i18n/locales/hi/chat.json
+++ b/webview-ui/src/i18n/locales/hi/chat.json
@@ -123,6 +123,7 @@
"separator": "विभाजक",
"edit": "संपादित करें...",
"forNextMode": "अगले मोड के लिए",
+ "forPreviousMode": "पिछले मोड के लिए",
"error": "त्रुटि",
"diffError": {
"title": "संपादन असफल"
diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json
index dce0426832..c6f9c57644 100644
--- a/webview-ui/src/i18n/locales/id/chat.json
+++ b/webview-ui/src/i18n/locales/id/chat.json
@@ -137,6 +137,7 @@
"separator": "Pemisah",
"edit": "Edit...",
"forNextMode": "untuk mode selanjutnya",
+ "forPreviousMode": "untuk mode sebelumnya",
"apiRequest": {
"title": "Permintaan API",
"failed": "Permintaan API Gagal",
diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json
index 084606605f..080d6883aa 100644
--- a/webview-ui/src/i18n/locales/it/chat.json
+++ b/webview-ui/src/i18n/locales/it/chat.json
@@ -123,6 +123,7 @@
"separator": "Separatore",
"edit": "Modifica...",
"forNextMode": "per la prossima modalità",
+ "forPreviousMode": "per la modalità precedente",
"instructions": {
"wantsToFetch": "Roo vuole recuperare istruzioni dettagliate per aiutare con l'attività corrente"
},
diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json
index dc69fdf742..6bb74ad5e2 100644
--- a/webview-ui/src/i18n/locales/ja/chat.json
+++ b/webview-ui/src/i18n/locales/ja/chat.json
@@ -123,6 +123,7 @@
"separator": "区切り",
"edit": "編集...",
"forNextMode": "次のモード用",
+ "forPreviousMode": "前のモード用",
"error": "エラー",
"diffError": {
"title": "編集に失敗しました"
diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json
index 1be0b4cb1b..812579a850 100644
--- a/webview-ui/src/i18n/locales/ko/chat.json
+++ b/webview-ui/src/i18n/locales/ko/chat.json
@@ -123,6 +123,7 @@
"separator": "구분자",
"edit": "편집...",
"forNextMode": "다음 모드용",
+ "forPreviousMode": "이전 모드용",
"error": "오류",
"diffError": {
"title": "편집 실패"
diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json
index d2834c700f..c0e5e92d66 100644
--- a/webview-ui/src/i18n/locales/nl/chat.json
+++ b/webview-ui/src/i18n/locales/nl/chat.json
@@ -123,6 +123,7 @@
"separator": "Scheidingsteken",
"edit": "Bewerken...",
"forNextMode": "voor volgende modus",
+ "forPreviousMode": "voor vorige modus",
"apiRequest": {
"title": "API-verzoek",
"failed": "API-verzoek mislukt",
diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json
index e8020219c0..3e59b4f981 100644
--- a/webview-ui/src/i18n/locales/pl/chat.json
+++ b/webview-ui/src/i18n/locales/pl/chat.json
@@ -123,6 +123,7 @@
"separator": "Separator",
"edit": "Edytuj...",
"forNextMode": "dla następnego trybu",
+ "forPreviousMode": "dla poprzedniego trybu",
"error": "Błąd",
"diffError": {
"title": "Edycja nieudana"
diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json
index a0b39e6c96..8f7dcd5dcd 100644
--- a/webview-ui/src/i18n/locales/pt-BR/chat.json
+++ b/webview-ui/src/i18n/locales/pt-BR/chat.json
@@ -123,6 +123,7 @@
"separator": "Separador",
"edit": "Editar...",
"forNextMode": "para o próximo modo",
+ "forPreviousMode": "para o modo anterior",
"error": "Erro",
"diffError": {
"title": "Edição mal-sucedida"
diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json
index 9c751fba8c..89502fbc76 100644
--- a/webview-ui/src/i18n/locales/ru/chat.json
+++ b/webview-ui/src/i18n/locales/ru/chat.json
@@ -123,6 +123,7 @@
"separator": "Разделитель",
"edit": "Редактировать...",
"forNextMode": "для следующего режима",
+ "forPreviousMode": "для предыдущего режима",
"apiRequest": {
"title": "API-запрос",
"failed": "API-запрос не выполнен",
diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json
index 951877d65e..94910edf89 100644
--- a/webview-ui/src/i18n/locales/tr/chat.json
+++ b/webview-ui/src/i18n/locales/tr/chat.json
@@ -123,6 +123,7 @@
"separator": "Ayırıcı",
"edit": "Düzenle...",
"forNextMode": "sonraki mod için",
+ "forPreviousMode": "önceki mod için",
"error": "Hata",
"diffError": {
"title": "Düzenleme Başarısız"
diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json
index 7588595280..02f27f9e37 100644
--- a/webview-ui/src/i18n/locales/vi/chat.json
+++ b/webview-ui/src/i18n/locales/vi/chat.json
@@ -123,6 +123,7 @@
"separator": "Dấu phân cách",
"edit": "Chỉnh sửa...",
"forNextMode": "cho chế độ tiếp theo",
+ "forPreviousMode": "cho chế độ trước đó",
"error": "Lỗi",
"diffError": {
"title": "Chỉnh sửa không thành công"
diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json
index 352da4c9d2..370543f294 100644
--- a/webview-ui/src/i18n/locales/zh-CN/chat.json
+++ b/webview-ui/src/i18n/locales/zh-CN/chat.json
@@ -123,6 +123,7 @@
"separator": "分隔符",
"edit": "编辑...",
"forNextMode": "用于下一个模式",
+ "forPreviousMode": "用于上一个模式",
"error": "错误",
"diffError": {
"title": "编辑失败"
diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json
index ff0a541aa1..3b9dcc795c 100644
--- a/webview-ui/src/i18n/locales/zh-TW/chat.json
+++ b/webview-ui/src/i18n/locales/zh-TW/chat.json
@@ -123,6 +123,7 @@
"separator": "分隔符號",
"edit": "編輯...",
"forNextMode": "用於下一個模式",
+ "forPreviousMode": "用於上一個模式",
"error": "錯誤",
"diffError": {
"title": "編輯失敗"
diff --git a/webview-ui/src/utils/__tests__/model-utils.spec.ts b/webview-ui/src/utils/__tests__/model-utils.spec.ts
index 7b630e906e..a8ae33300a 100644
--- a/webview-ui/src/utils/__tests__/model-utils.spec.ts
+++ b/webview-ui/src/utils/__tests__/model-utils.spec.ts
@@ -17,33 +17,33 @@ describe("calculateTokenDistribution", () => {
expect(Math.round(result.currentPercent + result.reservedPercent + result.availablePercent)).toBe(100)
})
- it("should default to 20% of context window when maxTokens not provided", () => {
- const contextWindow = 10000
+ it("should default to 8192 when maxTokens not provided", () => {
+ const contextWindow = 20000
const contextTokens = 5000
const result = calculateTokenDistribution(contextWindow, contextTokens)
- expect(result.reservedForOutput).toBe(2000) // 20% of 10000
- expect(result.availableSize).toBe(3000) // 10000 - 5000 - 2000
+ expect(result.reservedForOutput).toBe(8192)
+ expect(result.availableSize).toBe(6808) // 20000 - 5000 - 8192
})
it("should handle negative or zero inputs by using positive fallbacks", () => {
const result = calculateTokenDistribution(-1000, -500)
expect(result.currentPercent).toBe(0)
- expect(result.reservedPercent).toBe(0)
+ expect(result.reservedPercent).toBe(100) // 8192 / 8192 = 100%
expect(result.availablePercent).toBe(0)
- expect(result.reservedForOutput).toBe(0) // With negative inputs, both context window and tokens become 0, so 20% of 0 is 0
- expect(result.availableSize).toBe(0)
+ expect(result.reservedForOutput).toBe(8192) // Uses ANTHROPIC_DEFAULT_MAX_TOKENS
+ expect(result.availableSize).toBe(0) // max(0, 0 - 0 - 8192) = 0
})
- it("should handle zero total tokens without division by zero errors", () => {
- const result = calculateTokenDistribution(0, 0, 0)
+ it("should handle zero context window without division by zero errors", () => {
+ const result = calculateTokenDistribution(0, 0)
expect(result.currentPercent).toBe(0)
- expect(result.reservedPercent).toBe(0)
+ expect(result.reservedPercent).toBe(100) // When contextWindow is 0, reserved gets 100%
expect(result.availablePercent).toBe(0)
- expect(result.reservedForOutput).toBe(0)
+ expect(result.reservedForOutput).toBe(8192) // Uses ANTHROPIC_DEFAULT_MAX_TOKENS when no maxTokens provided
expect(result.availableSize).toBe(0)
})
})
diff --git a/webview-ui/src/utils/model-utils.ts b/webview-ui/src/utils/model-utils.ts
index 269f9865fb..6ac31f5f11 100644
--- a/webview-ui/src/utils/model-utils.ts
+++ b/webview-ui/src/utils/model-utils.ts
@@ -1,3 +1,5 @@
+import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types"
+
/**
* Result of token distribution calculation
*/
@@ -34,7 +36,7 @@ export interface TokenDistributionResult {
*
* @param contextWindow The total size of the context window
* @param contextTokens The number of tokens currently used
- * @param maxTokens Optional override for tokens reserved for model output (otherwise uses 20% of window)
+ * @param maxTokens Optional override for tokens reserved for model output (otherwise uses 8192)
* @returns Distribution of tokens with percentages and raw numbers
*/
export const calculateTokenDistribution = (
@@ -47,9 +49,9 @@ export const calculateTokenDistribution = (
const safeContextTokens = Math.max(0, contextTokens)
// Get the actual max tokens value from the model
- // If maxTokens is valid, use it, otherwise reserve 20% of the context window as a default
+ // If maxTokens is valid (positive and not equal to context window), use it, otherwise reserve 8192 tokens as a default
const reservedForOutput =
- maxTokens && maxTokens > 0 && maxTokens !== safeContextWindow ? maxTokens : Math.ceil(safeContextWindow * 0.2)
+ maxTokens && maxTokens > 0 && maxTokens !== safeContextWindow ? maxTokens : ANTHROPIC_DEFAULT_MAX_TOKENS
// Calculate sizes directly without buffer display
const availableSize = Math.max(0, safeContextWindow - safeContextTokens - reservedForOutput)
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() {}