Merge branch 'RooCodeInc:main' into feat/finer-grained-control-gemini

This commit is contained in:
Ton Hoang Nguyen (Bill) 2025-07-15 22:42:06 +01:00 committed by GitHub
commit ae0a3b79a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 1033 additions and 143 deletions

View file

@ -31,8 +31,6 @@ jobs:
ref: ${{ env.GIT_REF }}
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
with:
skip-checkout: 'true'
# Check if there are any new changesets to process
- name: Check for changesets

View file

@ -25,8 +25,6 @@ jobs:
ref: ${{ env.GIT_REF }}
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
with:
skip-checkout: 'true'
- name: Configure Git
run: |
git config user.name "github-actions[bot]"

View file

@ -20,7 +20,6 @@ jobs:
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
with:
skip-checkout: 'true'
install-args: '--frozen-lockfile'
- name: Forge numeric Nightly version
id: version

View file

@ -1,5 +1,19 @@
# Roo Code Changelog
## [3.23.12] - 2025-07-15
- Update the max-token calculation in model-params to better support Kimi K2 and others
## [3.23.11] - 2025-07-14
- Add Kimi K2 model to Groq along with fixes to context condensing math
- Add Cmd+Shift+. keyboard shortcut for previous mode switching
## [3.23.10] - 2025-07-14
- Prioritize built-in model dimensions over custom dimensions (thanks @daniel-lxs!)
- Add padding to the index model options
## [3.23.9] - 2025-07-14
- Enable Claude Code provider to run natively on Windows (thanks @SannidhyaSah!)

View file

@ -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

View 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")
})
})

View file

@ -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"],

View file

@ -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<string, ModelInfo>

View file

@ -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({

View file

@ -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,
})

View file

@ -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: {

View file

@ -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 = {}

View file

@ -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") {

View file

@ -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<ClineEvents> {
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

View file

@ -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,
}
}

View file

@ -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",

View file

@ -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)
})
})
})
})

View file

@ -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",
)
})

View file

@ -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
}
/**

View file

@ -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) {

View file

@ -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)
})
})

View file

@ -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
*/

View file

@ -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", () => {

View file

@ -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

View file

@ -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%

View file

@ -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)

View file

@ -79,7 +79,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
})
const { t } = useAppTranslation()
const { t: tSettings } = useTranslation("settings")
const modeShortcutText = `${isMac ? "⌘" : "Ctrl"} + . ${t("chat:forNextMode")}`
const modeShortcutText = `${isMac ? "⌘" : "Ctrl"} + . ${t("chat:forNextMode")}, ${isMac ? "⌘" : "Ctrl"} + Shift + . ${t("chat:forPreviousMode")}`
const {
clineMessages: messages,
currentTaskItem,
@ -1555,16 +1555,33 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
switchToMode(allModes[nextModeIndex].slug)
}, [mode, customModes, switchToMode])
// Function to handle switching to previous mode
const switchToPreviousMode = useCallback(() => {
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

View file

@ -643,7 +643,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
className={cn("w-full", {
"border-red-500": formErrors.codebaseIndexEmbedderModelId,
})}>
<VSCodeOption value="">
<VSCodeOption value="" className="p-2">
{t("settings:codeIndex.selectModel")}
</VSCodeOption>
{getAvailableModels().map((modelId) => {
@ -652,7 +652,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
currentSettings.codebaseIndexEmbedderProvider
]?.[modelId]
return (
<VSCodeOption key={modelId} value={modelId}>
<VSCodeOption key={modelId} value={modelId} className="p-2">
{modelId}{" "}
{model
? t("settings:codeIndex.modelDimensions", {
@ -717,7 +717,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
className={cn("w-full", {
"border-red-500": formErrors.codebaseIndexEmbedderModelId,
})}>
<VSCodeOption value="">
<VSCodeOption value="" className="p-2">
{t("settings:codeIndex.selectModel")}
</VSCodeOption>
{getAvailableModels().map((modelId) => {
@ -726,7 +726,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
currentSettings.codebaseIndexEmbedderProvider
]?.[modelId]
return (
<VSCodeOption key={modelId} value={modelId}>
<VSCodeOption key={modelId} value={modelId} className="p-2">
{modelId}{" "}
{model
? t("settings:codeIndex.modelDimensions", {
@ -890,7 +890,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
className={cn("w-full", {
"border-red-500": formErrors.codebaseIndexEmbedderModelId,
})}>
<VSCodeOption value="">
<VSCodeOption value="" className="p-2">
{t("settings:codeIndex.selectModel")}
</VSCodeOption>
{getAvailableModels().map((modelId) => {
@ -899,7 +899,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
currentSettings.codebaseIndexEmbedderProvider
]?.[modelId]
return (
<VSCodeOption key={modelId} value={modelId}>
<VSCodeOption key={modelId} value={modelId} className="p-2">
{modelId}{" "}
{model
? t("settings:codeIndex.modelDimensions", {

View file

@ -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", () => {

View file

@ -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"

View file

@ -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"

View file

@ -131,6 +131,7 @@
"separator": "Separator",
"edit": "Edit...",
"forNextMode": "for next mode",
"forPreviousMode": "for previous mode",
"apiRequest": {
"title": "API Request",
"failed": "API Request Failed",

View file

@ -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"

View file

@ -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"

View file

@ -123,6 +123,7 @@
"separator": "विभाजक",
"edit": "संपादित करें...",
"forNextMode": "अगले मोड के लिए",
"forPreviousMode": "पिछले मोड के लिए",
"error": "त्रुटि",
"diffError": {
"title": "संपादन असफल"

View file

@ -137,6 +137,7 @@
"separator": "Pemisah",
"edit": "Edit...",
"forNextMode": "untuk mode selanjutnya",
"forPreviousMode": "untuk mode sebelumnya",
"apiRequest": {
"title": "Permintaan API",
"failed": "Permintaan API Gagal",

View file

@ -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"
},

View file

@ -123,6 +123,7 @@
"separator": "区切り",
"edit": "編集...",
"forNextMode": "次のモード用",
"forPreviousMode": "前のモード用",
"error": "エラー",
"diffError": {
"title": "編集に失敗しました"

View file

@ -123,6 +123,7 @@
"separator": "구분자",
"edit": "편집...",
"forNextMode": "다음 모드용",
"forPreviousMode": "이전 모드용",
"error": "오류",
"diffError": {
"title": "편집 실패"

View file

@ -123,6 +123,7 @@
"separator": "Scheidingsteken",
"edit": "Bewerken...",
"forNextMode": "voor volgende modus",
"forPreviousMode": "voor vorige modus",
"apiRequest": {
"title": "API-verzoek",
"failed": "API-verzoek mislukt",

View file

@ -123,6 +123,7 @@
"separator": "Separator",
"edit": "Edytuj...",
"forNextMode": "dla następnego trybu",
"forPreviousMode": "dla poprzedniego trybu",
"error": "Błąd",
"diffError": {
"title": "Edycja nieudana"

View file

@ -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"

View file

@ -123,6 +123,7 @@
"separator": "Разделитель",
"edit": "Редактировать...",
"forNextMode": "для следующего режима",
"forPreviousMode": "для предыдущего режима",
"apiRequest": {
"title": "API-запрос",
"failed": "API-запрос не выполнен",

View file

@ -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"

View file

@ -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"

View file

@ -123,6 +123,7 @@
"separator": "分隔符",
"edit": "编辑...",
"forNextMode": "用于下一个模式",
"forPreviousMode": "用于上一个模式",
"error": "错误",
"diffError": {
"title": "编辑失败"

View file

@ -123,6 +123,7 @@
"separator": "分隔符號",
"edit": "編輯...",
"forNextMode": "用於下一個模式",
"forPreviousMode": "用於上一個模式",
"error": "錯誤",
"diffError": {
"title": "編輯失敗"

View file

@ -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)
})
})

View file

@ -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)

View file

@ -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() {}