mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Merge branch 'RooCodeInc:main' into feat/finer-grained-control-gemini
This commit is contained in:
commit
ae0a3b79a5
50 changed files with 1033 additions and 143 deletions
2
.github/workflows/changeset-release.yml
vendored
2
.github/workflows/changeset-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/marketplace-publish.yml
vendored
2
.github/workflows/marketplace-publish.yml
vendored
|
|
@ -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]"
|
||||
|
|
|
|||
1
.github/workflows/nightly-publish.yml
vendored
1
.github/workflows/nightly-publish.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -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!)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
41
packages/types/src/providers/__tests__/claude-code.spec.ts
Normal file
41
packages/types/src/providers/__tests__/claude-code.spec.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, test, expect } from "vitest"
|
||||
import { convertModelNameForVertex, getClaudeCodeModelId } from "../claude-code.js"
|
||||
|
||||
describe("convertModelNameForVertex", () => {
|
||||
test("should convert hyphen-date format to @date format", () => {
|
||||
expect(convertModelNameForVertex("claude-sonnet-4-20250514")).toBe("claude-sonnet-4@20250514")
|
||||
expect(convertModelNameForVertex("claude-opus-4-20250514")).toBe("claude-opus-4@20250514")
|
||||
expect(convertModelNameForVertex("claude-3-7-sonnet-20250219")).toBe("claude-3-7-sonnet@20250219")
|
||||
expect(convertModelNameForVertex("claude-3-5-sonnet-20241022")).toBe("claude-3-5-sonnet@20241022")
|
||||
expect(convertModelNameForVertex("claude-3-5-haiku-20241022")).toBe("claude-3-5-haiku@20241022")
|
||||
})
|
||||
|
||||
test("should not modify models without date pattern", () => {
|
||||
expect(convertModelNameForVertex("some-other-model")).toBe("some-other-model")
|
||||
expect(convertModelNameForVertex("claude-model")).toBe("claude-model")
|
||||
expect(convertModelNameForVertex("model-with-short-date-123")).toBe("model-with-short-date-123")
|
||||
})
|
||||
|
||||
test("should only convert 8-digit date patterns at the end", () => {
|
||||
expect(convertModelNameForVertex("claude-20250514-sonnet")).toBe("claude-20250514-sonnet")
|
||||
expect(convertModelNameForVertex("model-20250514-with-more")).toBe("model-20250514-with-more")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getClaudeCodeModelId", () => {
|
||||
test("should return original model when useVertex is false", () => {
|
||||
expect(getClaudeCodeModelId("claude-sonnet-4-20250514", false)).toBe("claude-sonnet-4-20250514")
|
||||
expect(getClaudeCodeModelId("claude-opus-4-20250514", false)).toBe("claude-opus-4-20250514")
|
||||
expect(getClaudeCodeModelId("claude-3-7-sonnet-20250219", false)).toBe("claude-3-7-sonnet-20250219")
|
||||
})
|
||||
|
||||
test("should return converted model when useVertex is true", () => {
|
||||
expect(getClaudeCodeModelId("claude-sonnet-4-20250514", true)).toBe("claude-sonnet-4@20250514")
|
||||
expect(getClaudeCodeModelId("claude-opus-4-20250514", true)).toBe("claude-opus-4@20250514")
|
||||
expect(getClaudeCodeModelId("claude-3-7-sonnet-20250219", true)).toBe("claude-3-7-sonnet@20250219")
|
||||
})
|
||||
|
||||
test("should default to useVertex false when parameter not provided", () => {
|
||||
expect(getClaudeCodeModelId("claude-sonnet-4-20250514")).toBe("claude-sonnet-4-20250514")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,10 +1,44 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
import { anthropicModels } from "./anthropic.js"
|
||||
|
||||
// Regex pattern to match 8-digit date at the end of model names
|
||||
const VERTEX_DATE_PATTERN = /-(\d{8})$/
|
||||
|
||||
/**
|
||||
* Converts Claude model names from hyphen-date format to Vertex AI's @-date format.
|
||||
*
|
||||
* @param modelName - The original model name (e.g., "claude-sonnet-4-20250514")
|
||||
* @returns The converted model name for Vertex AI (e.g., "claude-sonnet-4@20250514")
|
||||
*
|
||||
* @example
|
||||
* convertModelNameForVertex("claude-sonnet-4-20250514") // returns "claude-sonnet-4@20250514"
|
||||
* convertModelNameForVertex("claude-model") // returns "claude-model" (no change)
|
||||
*/
|
||||
export function convertModelNameForVertex(modelName: string): string {
|
||||
// Convert hyphen-date format to @date format for Vertex AI
|
||||
return modelName.replace(VERTEX_DATE_PATTERN, "@$1")
|
||||
}
|
||||
|
||||
// Claude Code
|
||||
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
|
||||
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-20250514"
|
||||
export const CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS = 8000
|
||||
|
||||
/**
|
||||
* Gets the appropriate model ID based on whether Vertex AI is being used.
|
||||
*
|
||||
* @param baseModelId - The base Claude Code model ID
|
||||
* @param useVertex - Whether to format the model ID for Vertex AI (default: false)
|
||||
* @returns The model ID, potentially formatted for Vertex AI
|
||||
*
|
||||
* @example
|
||||
* getClaudeCodeModelId("claude-sonnet-4-20250514", true) // returns "claude-sonnet-4@20250514"
|
||||
* getClaudeCodeModelId("claude-sonnet-4-20250514", false) // returns "claude-sonnet-4-20250514"
|
||||
*/
|
||||
export function getClaudeCodeModelId(baseModelId: ClaudeCodeModelId, useVertex = false): string {
|
||||
return useVertex ? convertModelNameForVertex(baseModelId) : baseModelId
|
||||
}
|
||||
|
||||
export const claudeCodeModels = {
|
||||
"claude-sonnet-4-20250514": {
|
||||
...anthropicModels["claude-sonnet-4-20250514"],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -89,6 +89,14 @@ export const taskPropertiesSchema = z.object({
|
|||
modelId: z.string().optional(),
|
||||
diffStrategy: z.string().optional(),
|
||||
isSubtask: z.boolean().optional(),
|
||||
todos: z
|
||||
.object({
|
||||
total: z.number(),
|
||||
completed: z.number(),
|
||||
inProgress: z.number(),
|
||||
pending: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const gitPropertiesSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { claudeCodeDefaultModelId, type ClaudeCodeModelId, claudeCodeModels, type ModelInfo } from "@roo-code/types"
|
||||
import {
|
||||
claudeCodeDefaultModelId,
|
||||
type ClaudeCodeModelId,
|
||||
claudeCodeModels,
|
||||
type ModelInfo,
|
||||
getClaudeCodeModelId,
|
||||
} from "@roo-code/types"
|
||||
import { type ApiHandler } from ".."
|
||||
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
|
||||
import { runClaudeCode } from "../../integrations/claude-code/run"
|
||||
|
|
@ -20,11 +26,17 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
|
|||
// Filter out image blocks since Claude Code doesn't support them
|
||||
const filteredMessages = filterMessagesForClaudeCode(messages)
|
||||
|
||||
const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1"
|
||||
const model = this.getModel()
|
||||
|
||||
// Validate that the model ID is a valid ClaudeCodeModelId
|
||||
const modelId = model.id in claudeCodeModels ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId
|
||||
|
||||
const claudeProcess = runClaudeCode({
|
||||
systemPrompt,
|
||||
messages: filteredMessages,
|
||||
path: this.options.claudeCodePath,
|
||||
modelId: this.getModel().id,
|
||||
modelId: getClaudeCodeModelId(modelId, useVertex),
|
||||
maxOutputTokens: this.options.claudeCodeMaxOutputTokens,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,132 @@ describe("getLiteLLMModels", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("handles base URLs with a path correctly", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
|
||||
mockedAxios.get.mockResolvedValue(mockResponse)
|
||||
|
||||
await getLiteLLMModels("test-api-key", "http://localhost:4000/litellm")
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/litellm/v1/model/info", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
"Content-Type": "application/json",
|
||||
...DEFAULT_HEADERS,
|
||||
},
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles base URLs with a path and trailing slash correctly", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
|
||||
mockedAxios.get.mockResolvedValue(mockResponse)
|
||||
|
||||
await getLiteLLMModels("test-api-key", "http://localhost:4000/litellm/")
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/litellm/v1/model/info", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
"Content-Type": "application/json",
|
||||
...DEFAULT_HEADERS,
|
||||
},
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles base URLs with double slashes correctly", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
|
||||
mockedAxios.get.mockResolvedValue(mockResponse)
|
||||
|
||||
await getLiteLLMModels("test-api-key", "http://localhost:4000/litellm//")
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/litellm/v1/model/info", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
"Content-Type": "application/json",
|
||||
...DEFAULT_HEADERS,
|
||||
},
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles base URLs with query parameters correctly", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
|
||||
mockedAxios.get.mockResolvedValue(mockResponse)
|
||||
|
||||
await getLiteLLMModels("test-api-key", "http://localhost:4000/litellm?key=value")
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/litellm/v1/model/info?key=value", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
"Content-Type": "application/json",
|
||||
...DEFAULT_HEADERS,
|
||||
},
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles base URLs with fragments correctly", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
|
||||
mockedAxios.get.mockResolvedValue(mockResponse)
|
||||
|
||||
await getLiteLLMModels("test-api-key", "http://localhost:4000/litellm#section")
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/litellm/v1/model/info#section", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
"Content-Type": "application/json",
|
||||
...DEFAULT_HEADERS,
|
||||
},
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles base URLs with port and no path correctly", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
|
||||
mockedAxios.get.mockResolvedValue(mockResponse)
|
||||
|
||||
await getLiteLLMModels("test-api-key", "http://localhost:4000")
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/v1/model/info", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
"Content-Type": "application/json",
|
||||
...DEFAULT_HEADERS,
|
||||
},
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
it("successfully fetches and formats LiteLLM models", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise
|
|||
headers["Authorization"] = `Bearer ${apiKey}`
|
||||
}
|
||||
// Use URL constructor to properly join base URL and path
|
||||
const url = new URL("/v1/model/info", baseUrl).href
|
||||
// This approach handles all edge cases including paths, query params, and fragments
|
||||
const urlObj = new URL(baseUrl)
|
||||
// Normalize the pathname by removing trailing slashes and multiple slashes
|
||||
urlObj.pathname = urlObj.pathname.replace(/\/+$/, "").replace(/\/+/g, "/") + "/v1/model/info"
|
||||
const url = urlObj.href
|
||||
// Added timeout to prevent indefinite hanging
|
||||
const response = await axios.get(url, { headers, timeout: 5000 })
|
||||
const models: ModelRecord = {}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, it, expect, vi } from "vitest"
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest"
|
||||
import * as path from "path"
|
||||
import { listFiles } from "../list-files"
|
||||
import * as childProcess from "child_process"
|
||||
|
||||
vi.mock("../list-files", async () => {
|
||||
const actual = await vi.importActual("../list-files")
|
||||
|
|
@ -16,3 +18,201 @@ describe("listFiles", () => {
|
|||
expect(result).toEqual([[], false])
|
||||
})
|
||||
})
|
||||
|
||||
// Mock ripgrep to avoid filesystem dependencies
|
||||
vi.mock("../../ripgrep", () => ({
|
||||
getBinPath: vi.fn().mockResolvedValue("/mock/path/to/rg"),
|
||||
}))
|
||||
|
||||
// Mock vscode
|
||||
vi.mock("vscode", () => ({
|
||||
env: {
|
||||
appRoot: "/mock/app/root",
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock filesystem operations
|
||||
vi.mock("fs", () => ({
|
||||
promises: {
|
||||
access: vi.fn().mockRejectedValue(new Error("Not found")),
|
||||
readFile: vi.fn().mockResolvedValue(""),
|
||||
readdir: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}))
|
||||
|
||||
// Import fs to set up mocks
|
||||
import * as fs from "fs"
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
spawn: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../../path", () => ({
|
||||
arePathsEqual: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
describe("list-files symlink support", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should include --follow flag in ripgrep arguments", async () => {
|
||||
const mockSpawn = vi.mocked(childProcess.spawn)
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "data") {
|
||||
// Simulate some output to complete the process
|
||||
setTimeout(() => callback("test-file.txt\n"), 10)
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => callback(0), 20)
|
||||
}
|
||||
if (event === "error") {
|
||||
// No error simulation
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}
|
||||
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Call listFiles to trigger ripgrep execution
|
||||
await listFiles("/test/dir", false, 100)
|
||||
|
||||
// Verify that spawn was called with --follow flag (the critical fix)
|
||||
const [rgPath, args] = mockSpawn.mock.calls[0]
|
||||
expect(rgPath).toBe("/mock/path/to/rg")
|
||||
expect(args).toContain("--files")
|
||||
expect(args).toContain("--hidden")
|
||||
expect(args).toContain("--follow") // This is the critical assertion - the fix should add this flag
|
||||
|
||||
// Platform-agnostic path check - verify the last argument is the resolved path
|
||||
const expectedPath = path.resolve("/test/dir")
|
||||
expect(args[args.length - 1]).toBe(expectedPath)
|
||||
})
|
||||
|
||||
it("should include --follow flag for recursive listings too", async () => {
|
||||
const mockSpawn = vi.mocked(childProcess.spawn)
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "data") {
|
||||
setTimeout(() => callback("test-file.txt\n"), 10)
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => callback(0), 20)
|
||||
}
|
||||
if (event === "error") {
|
||||
// No error simulation
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}
|
||||
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Call listFiles with recursive=true
|
||||
await listFiles("/test/dir", true, 100)
|
||||
|
||||
// Verify that spawn was called with --follow flag (the critical fix)
|
||||
const [rgPath, args] = mockSpawn.mock.calls[0]
|
||||
expect(rgPath).toBe("/mock/path/to/rg")
|
||||
expect(args).toContain("--files")
|
||||
expect(args).toContain("--hidden")
|
||||
expect(args).toContain("--follow") // This should be present in recursive mode too
|
||||
|
||||
// Platform-agnostic path check - verify the last argument is the resolved path
|
||||
const expectedPath = path.resolve("/test/dir")
|
||||
expect(args[args.length - 1]).toBe(expectedPath)
|
||||
})
|
||||
|
||||
it("should ensure first-level directories are included when limit is reached", async () => {
|
||||
// Mock fs.promises.readdir to simulate a directory structure
|
||||
const mockReaddir = vi.mocked(fs.promises.readdir)
|
||||
|
||||
// Root directory with first-level directories
|
||||
mockReaddir.mockResolvedValueOnce([
|
||||
{ name: "a_dir", isDirectory: () => true, isSymbolicLink: () => false, isFile: () => false } as any,
|
||||
{ name: "b_dir", isDirectory: () => true, isSymbolicLink: () => false, isFile: () => false } as any,
|
||||
{ name: "c_dir", isDirectory: () => true, isSymbolicLink: () => false, isFile: () => false } as any,
|
||||
{ name: "file1.txt", isDirectory: () => false, isSymbolicLink: () => false, isFile: () => true } as any,
|
||||
{ name: "file2.txt", isDirectory: () => false, isSymbolicLink: () => false, isFile: () => true } as any,
|
||||
])
|
||||
|
||||
// Mock ripgrep to return many files (simulating hitting the limit)
|
||||
const mockSpawn = vi.mocked(childProcess.spawn)
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "data") {
|
||||
// Return many file paths to trigger the limit
|
||||
const paths =
|
||||
[
|
||||
"/test/dir/a_dir/",
|
||||
"/test/dir/a_dir/subdir1/",
|
||||
"/test/dir/a_dir/subdir1/file1.txt",
|
||||
"/test/dir/a_dir/subdir1/file2.txt",
|
||||
"/test/dir/a_dir/subdir2/",
|
||||
"/test/dir/a_dir/subdir2/file3.txt",
|
||||
"/test/dir/a_dir/file4.txt",
|
||||
"/test/dir/a_dir/file5.txt",
|
||||
"/test/dir/file1.txt",
|
||||
"/test/dir/file2.txt",
|
||||
// Note: b_dir and c_dir are missing from ripgrep output
|
||||
].join("\n") + "\n"
|
||||
setTimeout(() => callback(paths), 10)
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => callback(0), 20)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Mock fs.promises.access to simulate .gitignore doesn't exist
|
||||
vi.mocked(fs.promises.access).mockRejectedValue(new Error("File not found"))
|
||||
|
||||
// Call listFiles with recursive=true and a small limit
|
||||
const [results, limitReached] = await listFiles("/test/dir", true, 10)
|
||||
|
||||
// Verify that we got results and hit the limit
|
||||
expect(results.length).toBe(10)
|
||||
expect(limitReached).toBe(true)
|
||||
|
||||
// Count directories in results
|
||||
const directories = results.filter((r) => r.endsWith("/"))
|
||||
|
||||
// We should have at least the 3 first-level directories
|
||||
// even if ripgrep didn't return all of them
|
||||
expect(directories.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Verify all first-level directories are included
|
||||
const hasADir = results.some((r) => r.endsWith("a_dir/"))
|
||||
const hasBDir = results.some((r) => r.endsWith("b_dir/"))
|
||||
const hasCDir = results.some((r) => r.endsWith("c_dir/"))
|
||||
|
||||
expect(hasADir).toBe(true)
|
||||
expect(hasBDir).toBe(true)
|
||||
expect(hasCDir).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -32,15 +32,105 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
|
|||
// Get ripgrep path
|
||||
const rgPath = await getRipgrepPath()
|
||||
|
||||
// Get files using ripgrep
|
||||
const files = await listFilesWithRipgrep(rgPath, dirPath, recursive, limit)
|
||||
if (!recursive) {
|
||||
// For non-recursive, use the existing approach
|
||||
const files = await listFilesWithRipgrep(rgPath, dirPath, false, limit)
|
||||
const ignoreInstance = await createIgnoreInstance(dirPath)
|
||||
const directories = await listFilteredDirectories(dirPath, false, ignoreInstance)
|
||||
return formatAndCombineResults(files, directories, limit)
|
||||
}
|
||||
|
||||
// Get directories with proper filtering using ignore library
|
||||
// For recursive mode, use the original approach but ensure first-level directories are included
|
||||
const files = await listFilesWithRipgrep(rgPath, dirPath, true, limit)
|
||||
const ignoreInstance = await createIgnoreInstance(dirPath)
|
||||
const directories = await listFilteredDirectories(dirPath, recursive, ignoreInstance)
|
||||
const directories = await listFilteredDirectories(dirPath, true, ignoreInstance)
|
||||
|
||||
// Combine and format the results
|
||||
return formatAndCombineResults(files, directories, limit)
|
||||
// Combine and check if we hit the limit
|
||||
const [results, limitReached] = formatAndCombineResults(files, directories, limit)
|
||||
|
||||
// If we hit the limit, ensure all first-level directories are included
|
||||
if (limitReached) {
|
||||
const firstLevelDirs = await getFirstLevelDirectories(dirPath, ignoreInstance)
|
||||
return ensureFirstLevelDirectoriesIncluded(results, firstLevelDirs, limit)
|
||||
}
|
||||
|
||||
return [results, limitReached]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only the first-level directories in a path
|
||||
*/
|
||||
async function getFirstLevelDirectories(dirPath: string, ignoreInstance: ReturnType<typeof ignore>): Promise<string[]> {
|
||||
const absolutePath = path.resolve(dirPath)
|
||||
const directories: string[] = []
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(absolutePath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
const fullDirPath = path.join(absolutePath, entry.name)
|
||||
if (shouldIncludeDirectory(entry.name, fullDirPath, dirPath, ignoreInstance)) {
|
||||
const formattedPath = fullDirPath.endsWith("/") ? fullDirPath : `${fullDirPath}/`
|
||||
directories.push(formattedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Could not read directory ${absolutePath}: ${err}`)
|
||||
}
|
||||
|
||||
return directories
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all first-level directories are included in the results
|
||||
*/
|
||||
function ensureFirstLevelDirectoriesIncluded(
|
||||
results: string[],
|
||||
firstLevelDirs: string[],
|
||||
limit: number,
|
||||
): [string[], boolean] {
|
||||
// Create a set of existing paths for quick lookup
|
||||
const existingPaths = new Set(results)
|
||||
|
||||
// Find missing first-level directories
|
||||
const missingDirs = firstLevelDirs.filter((dir) => !existingPaths.has(dir))
|
||||
|
||||
if (missingDirs.length === 0) {
|
||||
// All first-level directories are already included
|
||||
return [results, true]
|
||||
}
|
||||
|
||||
// We need to make room for the missing directories
|
||||
// Remove items from the end (which are likely deeper in the tree)
|
||||
const itemsToRemove = Math.min(missingDirs.length, results.length)
|
||||
const adjustedResults = results.slice(0, results.length - itemsToRemove)
|
||||
|
||||
// Add the missing directories at the beginning (after any existing first-level dirs)
|
||||
// First, separate existing results into first-level and others
|
||||
const resultPaths = adjustedResults.map((r) => path.resolve(r))
|
||||
const basePath = path.resolve(firstLevelDirs[0]).split(path.sep).slice(0, -1).join(path.sep)
|
||||
|
||||
const firstLevelResults: string[] = []
|
||||
const otherResults: string[] = []
|
||||
|
||||
for (let i = 0; i < adjustedResults.length; i++) {
|
||||
const resolvedPath = resultPaths[i]
|
||||
const relativePath = path.relative(basePath, resolvedPath)
|
||||
const depth = relativePath.split(path.sep).length
|
||||
|
||||
if (depth === 1) {
|
||||
firstLevelResults.push(adjustedResults[i])
|
||||
} else {
|
||||
otherResults.push(adjustedResults[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Combine: existing first-level dirs + missing first-level dirs + other results
|
||||
const finalResults = [...firstLevelResults, ...missingDirs, ...otherResults].slice(0, limit)
|
||||
|
||||
return [finalResults, true]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -312,7 +402,6 @@ function isDirectoryExplicitlyIgnored(dirName: string): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Combine file and directory results and format them properly
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@
|
|||
"separator": "Separator",
|
||||
"edit": "Edit...",
|
||||
"forNextMode": "for next mode",
|
||||
"forPreviousMode": "for previous mode",
|
||||
"apiRequest": {
|
||||
"title": "API Request",
|
||||
"failed": "API Request Failed",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"separator": "विभाजक",
|
||||
"edit": "संपादित करें...",
|
||||
"forNextMode": "अगले मोड के लिए",
|
||||
"forPreviousMode": "पिछले मोड के लिए",
|
||||
"error": "त्रुटि",
|
||||
"diffError": {
|
||||
"title": "संपादन असफल"
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@
|
|||
"separator": "Pemisah",
|
||||
"edit": "Edit...",
|
||||
"forNextMode": "untuk mode selanjutnya",
|
||||
"forPreviousMode": "untuk mode sebelumnya",
|
||||
"apiRequest": {
|
||||
"title": "Permintaan API",
|
||||
"failed": "Permintaan API Gagal",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"separator": "区切り",
|
||||
"edit": "編集...",
|
||||
"forNextMode": "次のモード用",
|
||||
"forPreviousMode": "前のモード用",
|
||||
"error": "エラー",
|
||||
"diffError": {
|
||||
"title": "編集に失敗しました"
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"separator": "구분자",
|
||||
"edit": "편집...",
|
||||
"forNextMode": "다음 모드용",
|
||||
"forPreviousMode": "이전 모드용",
|
||||
"error": "오류",
|
||||
"diffError": {
|
||||
"title": "편집 실패"
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"separator": "Scheidingsteken",
|
||||
"edit": "Bewerken...",
|
||||
"forNextMode": "voor volgende modus",
|
||||
"forPreviousMode": "voor vorige modus",
|
||||
"apiRequest": {
|
||||
"title": "API-verzoek",
|
||||
"failed": "API-verzoek mislukt",
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"separator": "Separator",
|
||||
"edit": "Edytuj...",
|
||||
"forNextMode": "dla następnego trybu",
|
||||
"forPreviousMode": "dla poprzedniego trybu",
|
||||
"error": "Błąd",
|
||||
"diffError": {
|
||||
"title": "Edycja nieudana"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"separator": "Разделитель",
|
||||
"edit": "Редактировать...",
|
||||
"forNextMode": "для следующего режима",
|
||||
"forPreviousMode": "для предыдущего режима",
|
||||
"apiRequest": {
|
||||
"title": "API-запрос",
|
||||
"failed": "API-запрос не выполнен",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"separator": "分隔符",
|
||||
"edit": "编辑...",
|
||||
"forNextMode": "用于下一个模式",
|
||||
"forPreviousMode": "用于上一个模式",
|
||||
"error": "错误",
|
||||
"diffError": {
|
||||
"title": "编辑失败"
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"separator": "分隔符號",
|
||||
"edit": "編輯...",
|
||||
"forNextMode": "用於下一個模式",
|
||||
"forPreviousMode": "用於上一個模式",
|
||||
"error": "錯誤",
|
||||
"diffError": {
|
||||
"title": "編輯失敗"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import "@testing-library/jest-dom"
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
|
||||
// Force React into development mode for tests
|
||||
// This is needed to enable act(...) function in React Testing Library
|
||||
globalThis.process = globalThis.process || {}
|
||||
globalThis.process.env = globalThis.process.env || {}
|
||||
globalThis.process.env.NODE_ENV = "development"
|
||||
|
||||
class MockResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue