diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2d1819b4e1..02d38ddfa0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -150,6 +150,12 @@ jobs: if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true' run: xvfb-run -a pnpm --filter @roo-code/vscode-e2e test:ci:mock + - name: Run mocked restart-persistence E2E test + # Reuse the runner built by the full mocked suite; this direct invocation avoids + # test:run's dotenv loading and does not repeat the bundle or webview build. + if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true' + run: TEST_FILE=restart-persistence.test USE_MOCK=true xvfb-run -a pnpm --filter @roo-code/vscode-e2e exec node ./out/runTest.js + - name: Explain skipped mocked E2E pass marker if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success' && steps.vscode-fallback.outputs.used == 'true' run: echo "Skipping mocked E2E pass marker because tests ran against a stale cached VS Code binary (VS Code download endpoints unreachable)." diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 6160ea6be0..ec6a8c540b 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -5,6 +5,13 @@ on: pull_request: types: [opened, reopened, ready_for_review, synchronize] paths: + - "apps/vscode-e2e/package.json" + - "apps/vscode-e2e/src/theme-fixtures/**" + - "packages/types/src/api.ts" + - "packages/types/src/vscode-extension-host.ts" + - "src/core/webview/ClineProvider.ts" + - "src/core/webview/webviewMessageHandler.ts" + - "src/extension/api.ts" - "webview-ui/**" - "src/shared/**" - "package.json" @@ -55,3 +62,32 @@ jobs: webview-ui/playwright-report webview-ui/test-results if-no-files-found: ignore + + theme-fixtures: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + with: + install-args: "--frozen-lockfile" + - name: Install xvfb + run: sudo apt-get install -y xvfb + - name: Get pinned VS Code version + id: vscode-version + run: | + VERSION=$(node -p 'require("./apps/vscode-e2e/package.json").devDependencies["@types/vscode"]') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Cache pinned VS Code + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: apps/vscode-e2e/.vscode-test/ + key: vscode-theme-fixtures-${{ runner.os }}-${{ steps.vscode-version.outputs.version }}-v1 + - name: Test theme fixture serialization + run: pnpm --filter @roo-code/vscode-e2e themes:test + - name: Check generated VS Code theme fixtures + run: xvfb-run -a pnpm --filter @roo-code/vscode-e2e themes:check diff --git a/CHANGELOG.md b/CHANGELOG.md index f1abe84a66..72bfdbd415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Zoo Code Changelog +## [3.78.0] + +### Minor Changes + +- Add NanoGPT as a configurable provider with dynamic model discovery, streaming and prompt completions, and routing preferences for speed, price, latency, throughput, tool support, and caching (PR #1239 by @taltas) +- Add the new Gemini 3.7 Flash model to Google Gemini and Vertex AI with a 1M context window, multimodal input, prompt caching, and configurable reasoning (PR #1241 by @app/zoomote) +- Add the new GLM 5.3 model to Z AI coding plans and OpenCode Go with a 1M context window, prompt caching, and extended reasoning controls (PR #1244 by @app/zoomote) +- Add the new Qwen3.8 Max model to OpenCode Go with multimodal input, caching, streamed reasoning, and Anthropic Messages routing (PR #1245 by @app/zoomote) +- Fix Azure OpenAI resource endpoints and improve Azure-specific setup guidance in OpenAI Compatible settings (#1191 by @edelauna, PR #1192 by @app/zoomote) +- Preserve task-history titles when rapidly navigating away before a task's messages finish loading (#1180 by @edelauna, PR #1181 by @edelauna) +- Correct Kimi Code output-token defaults and honor model limits returned by the server (#1215 by @myk1yt, PR #1217 by @myk1yt) +- Update DeepSeek V4 Pro reasoning efforts and normalize medium, high, and extended reasoning mappings (#1235 by @WHMHammer, PR #1236 by @WHMHammer) +- Correct DeepSeek V4 pricing and expand provider coverage for the V4 Pro 0813 checkpoint (PR #1237 by @app/zoomote) +- Rename the default settings import/export file to `zoo-code-settings.json` throughout the extension (#1176 by @Rafael-Silva-Oliveira, PR #1177 by @Rafael-Silva-Oliveira) +- Add Destructive Command Guard support for Intel-based macOS systems (PR #1213 by @app/zoomote) +- Update `undici` to 6.28.0 to address security vulnerabilities (PR #1161 by @app/renovate) +- Update Mermaid to 11.16.1 to address a prototype-pollution vulnerability (PR #1193 by @app/renovate) +- Record tool usage centrally to prevent duplicate telemetry and sanitize raw tool names (PR #1073 by @edelauna) +- Canonicalize shared provider settings identifiers and add registry-alignment coverage (PR #1109 by @WebMad) +- Canonicalize provider identifiers across CLI configuration, environment mappings, and model selection (PR #1110 by @WebMad) +- Complete the webview migration to canonical provider identifiers across provider settings and routing (PR #1141 by @WebMad) +- Use canonical provider identifiers throughout API options and add focused interaction coverage (PR #1146 by @WebMad) +- Canonicalize provider model configuration identifiers and provider-specific settings behavior (PR #1147 by @WebMad) +- Migrate model-selection UI hooks to canonical provider identifiers (PR #1148 by @WebMad) +- Reuse the retired Roo provider identifier registry while preserving migration compatibility (PR #1166 by @WebMad) +- Introduce typed shared test utilities for API options, filesystem mocks, reset operations, VS Code doubles, and webview rendering (PR #1171 by @app/zoomote) +- Reuse shared API option factories across Requesty, OpenRouter, and Vercel AI Gateway provider tests (PR #1178 by @app/zoomote) +- Reuse the shared Responses client mock in X.AI provider tests (PR #1182 by @app/zoomote) +- Reuse shared VS Code context, URI, and reset helpers in custom-mode configuration tests (PR #1190 by @app/zoomote) +- Reuse shared reset helpers across code-index embedder tests (PR #1194 by @app/zoomote) +- Reuse shared config test helpers and remove obsolete lint suppressions (PR #1195 by @app/zoomote) +- Reuse shared webview render helpers across focused chat tests (PR #1196 by @app/zoomote) +- Reuse shared reset helpers across terminal integration tests (PR #1197 by @app/zoomote) +- Reuse shared VS Code and reset helpers in settings import/export tests (PR #1198 by @app/zoomote) +- Reuse shared reset helpers across additional code-index tests (PR #1199 by @app/zoomote) +- Complete another batch of shared reset-helper adoption in code-index tests (PR #1200 by @app/zoomote) +- Finish reset-helper adoption in Semble and terminal test suites (PR #1201 by @app/zoomote) +- Complete shared reset-helper adoption across provider tests (PR #1202 by @app/zoomote) +- Reuse shared webview render helpers across chat and settings tests (PR #1203 by @app/zoomote) +- Complete shared webview render-helper adoption in the remaining settings tests (PR #1204 by @app/zoomote) +- Merge the v3.76.0 release preparation branch into `main` (PR #1173 by @navedmerchant) + ## [3.76.0] ### Minor Changes diff --git a/PRIVACY.md b/PRIVACY.md index 2f589cf3ea..f43760023d 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Zoo Code Privacy Policy -**Last Updated: May 13th, 2026** +**Last Updated: August 15th, 2026** Zoo Code respects your privacy and is committed to transparency about how we handle your data. Below is a simple breakdown of where key pieces of data @@ -32,14 +32,18 @@ go—and, importantly, where they don't. - **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI model), it is stored locally on your device and never sent to us or any third party, except the provider you have chosen. -- **Telemetry (Usage Data)**: We collect feature usage and error data to help - us improve Zoo Code. This telemetry is powered by PostHog and includes your - VS Code machine ID, feature usage patterns, and exception reports. The VS Code +- **Telemetry (Usage Data)**: We collect feature usage and error data to help us + improve Zoo Code. This telemetry is powered by PostHog and includes your VS + Code machine ID, feature usage patterns, and exception reports. The VS Code machine ID is a persistent identifier and may be considered personal data in some jurisdictions; we use it only for product analytics and error grouping. - We retain telemetry only as long as needed for product analytics and debugging. - Telemetry does **not** collect your code or AI prompts, and you can opt out at - any time through the settings. + PostHog event data is retained for 12 months under our current project + configuration. This PostHog-based telemetry does **not** collect your code or AI + prompts. Telemetry is on by default. To turn it off, choose "Disabled" in + the settings or in the notice shown on first use. The notice remains until + you explicitly Accept or Decline. Telemetry also follows VS Code's own + global telemetry setting: if you turn that off, Zoo Code stops sending + telemetry right away, even if the Zoo Code setting says "Enabled." - **Marketplace Requests**: When you browse or search the Marketplace for Model Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API call to Zoo Code's backend servers to retrieve listing information. These diff --git a/README.md b/README.md index b9adb81960..b0bd6a083c 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,13 @@ Zoo Code builds on the foundation created by Roo Code and continues to expand it - **More dependable terminal and editing workflows** — fixes for premature terminal completion, task-state races, context management, diff editing, and provider-specific tool use. - **More control over your workspace** — rules management, per-mode MCP restrictions, multi-root path controls, model reasoning options, and completion change review actions. -## What's New in v3.76.0 +## What's New in v3.78.0 -- **Run longer, uninterrupted tasks with Destructive Command Guard (DCG)** — DCG blocks dangerous commands while letting Zoo keep working without you continuously pressing approval buttons, backed by hardened managed-binary downloads and installation. -- **Better provider controls and reliability** — choose OpenAI Codex response speed, use updated DeepSeek configurations, and benefit from stronger isolation between provider-profile changes and running tasks. -- **Critical terminal execution fix** — Zoo now waits for terminal commands to finish before starting the next step, preventing overlapping work and premature model continuation. -- Smarter batching groups related tool approvals while keeping unrelated requests separate. -- Telemetry delivery and model-cache fetching are more resilient under failures and concurrent requests. +- **Three major new models have arrived** — use the brand-new Gemini 3.7 Flash, GLM 5.3, and Qwen3.8 Max models, plus updated DeepSeek V4 reasoning, pricing, and provider coverage. +- **Connect to NanoGPT** — use dynamic model discovery, streaming and prompt completions, and routing preferences for speed, price, latency, throughput, tool support, and caching. +- **More reliable providers and tasks** — fixes improve Azure OpenAI endpoint setup, Kimi Code output limits, task-history title preservation, and Zoo settings import/export. +- Destructive Command Guard now supports Intel-based Macs. +- Security updates address vulnerabilities in `undici` and Mermaid.
🌐 Available languages diff --git a/apps/cli/src/lib/utils/__tests__/context-window.test.ts b/apps/cli/src/lib/utils/__tests__/context-window.test.ts index 8d33ef5e2b..4347292166 100644 --- a/apps/cli/src/lib/utils/__tests__/context-window.test.ts +++ b/apps/cli/src/lib/utils/__tests__/context-window.test.ts @@ -14,6 +14,7 @@ describe("getContextWindow", () => { [providerIdentifiers.vercelAiGateway, "vercelAiGatewayModelId"], [providerIdentifiers.opencodeGo, "opencodeGoModelId"], [providerIdentifiers.kenari, "kenariModelId"], + [providerIdentifiers.nanogpt, "nanoGptModelId"], [providerIdentifiers.zooGateway, "zooGatewayModelId"], ] as const)("uses the provider-specific model field for %s", (provider, modelField) => { const config = { apiProvider: provider, [modelField]: "selected-model" } as ProviderSettings diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index 1d6402c525..b78ab0e5b5 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -56,6 +56,8 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { return config.opencodeGoModelId case providerIdentifiers.kenari: return config.kenariModelId + case providerIdentifiers.nanogpt: + return config.nanoGptModelId case providerIdentifiers.zooGateway: return config.zooGatewayModelId case providerIdentifiers.anthropic: diff --git a/apps/vscode-e2e/fixtures/restart-persistence.json b/apps/vscode-e2e/fixtures/restart-persistence.json new file mode 100644 index 0000000000..240b91da20 --- /dev/null +++ b/apps/vscode-e2e/fixtures/restart-persistence.json @@ -0,0 +1,18 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "RESTART_PERSISTENCE_SMOKE" + }, + "response": { + "toolCalls": [ + { + "name": "attempt_completion", + "arguments": "{\"result\":\"RESTART_PERSISTENCE_MARKER\"}", + "id": "call_restart_persistence_done" + } + ] + } + } + ] +} diff --git a/apps/vscode-e2e/package.json b/apps/vscode-e2e/package.json index 3b1a3fb18f..ac1c71d6a1 100644 --- a/apps/vscode-e2e/package.json +++ b/apps/vscode-e2e/package.json @@ -9,6 +9,10 @@ "test:ci:mock": "pnpm -w bundle && pnpm --filter @roo-code/vscode-webview build && USE_MOCK=true pnpm test:run", "test:record": "AIMOCK_RECORD=true pnpm test:ci", "test:run": "rimraf out && tsc -p tsconfig.json && dotenv -e .env.local -- node ./out/runTest.js", + "themes:build": "pnpm -w bundle && pnpm --filter @roo-code/vscode-webview build && rimraf out && tsc -p tsconfig.json", + "themes:update": "pnpm themes:build && node ./out/theme-fixtures/generate.js update", + "themes:check": "pnpm themes:build && node ./out/theme-fixtures/generate.js check", + "themes:test": "pnpm --filter @roo-code/types build && rimraf out && tsc -p tsconfig.json && node --test ./out/theme-fixtures/fixtures.test.js", "clean": "rimraf out .turbo" }, "devDependencies": { diff --git a/apps/vscode-e2e/src/restart/phaseProtocol.ts b/apps/vscode-e2e/src/restart/phaseProtocol.ts new file mode 100644 index 0000000000..06ddc5f508 --- /dev/null +++ b/apps/vscode-e2e/src/restart/phaseProtocol.ts @@ -0,0 +1,107 @@ +import * as path from "path" +import * as fs from "fs/promises" + +import { createWriteStream } from "fs" + +export const PHASE_RESULT_VERSION = 1 as const + +export type RestartPhase = "create" | "verify" +export type PhaseStatus = "passed" | "failed" + +export type PhaseError = { + message: string + stack?: string +} + +export type PhaseResult = { + version: typeof PHASE_RESULT_VERSION + phase: RestartPhase + status: PhaseStatus + values?: Record + error?: PhaseError +} + +const phaseResultNames: Record = { + create: "01-create.json", + verify: "02-verify.json", +} + +export function getPhaseResultPath(resultsDir: string, phase: RestartPhase): string { + const resolvedResultsDir = path.resolve(resultsDir) + const resultPath = path.resolve(resolvedResultsDir, phaseResultNames[phase]) + if (path.dirname(resultPath) !== resolvedResultsDir) { + throw new Error(`Phase result path escaped the results directory: ${phase}`) + } + return resultPath +} + +export function serializePhaseError(error: unknown): PhaseError { + if (error instanceof Error) { + return { + message: error.message.slice(0, 2_000), + ...(error.stack && { stack: error.stack.slice(0, 4_000) }), + } + } + + return { message: String(error).slice(0, 2_000) } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +export function validatePhaseResult(value: unknown): asserts value is PhaseResult { + if (!isRecord(value) || value.version !== PHASE_RESULT_VERSION) { + throw new Error("Invalid phase result version") + } + if (value.phase !== "create" && value.phase !== "verify") { + throw new Error("Invalid phase result phase") + } + if (value.status !== "passed" && value.status !== "failed") { + throw new Error("Invalid phase result status") + } + if (value.values !== undefined) { + if (!isRecord(value.values) || Object.values(value.values).some((entry) => typeof entry !== "string")) { + throw new Error("Phase result values must be string-valued") + } + } + if (value.error !== undefined) { + if (!isRecord(value.error) || typeof value.error.message !== "string") { + throw new Error("Invalid phase result error") + } + if (value.error.stack !== undefined && typeof value.error.stack !== "string") { + throw new Error("Invalid phase result error stack") + } + } +} + +export async function writePhaseResult(resultsDir: string, result: PhaseResult): Promise { + validatePhaseResult(result) + const targetPath = getPhaseResultPath(resultsDir, result.phase) + const temporaryPath = `${targetPath}.${process.pid}.${Date.now()}.tmp` + try { + await writeJsonAtomically(temporaryPath, result) + await fs.rename(temporaryPath, targetPath) + } finally { + await fs.rm(temporaryPath, { force: true }) + } +} + +async function writeJsonAtomically(filePath: string, value: PhaseResult): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await new Promise((resolve, reject) => { + const stream = createWriteStream(filePath, { encoding: "utf8" }) + stream.once("error", reject) + stream.once("finish", resolve) + stream.end(JSON.stringify(value)) + }) +} + +export async function readPhaseResult(resultsDir: string, phase: RestartPhase): Promise { + const result = JSON.parse(await fs.readFile(getPhaseResultPath(resultsDir, phase), "utf8")) as unknown + validatePhaseResult(result) + if (result.phase !== phase) { + throw new Error(`Phase result does not match requested phase: ${phase}`) + } + return result +} diff --git a/apps/vscode-e2e/src/restart/scenarioWorkspace.ts b/apps/vscode-e2e/src/restart/scenarioWorkspace.ts new file mode 100644 index 0000000000..9ce49deaf0 --- /dev/null +++ b/apps/vscode-e2e/src/restart/scenarioWorkspace.ts @@ -0,0 +1,58 @@ +import * as os from "os" +import * as path from "path" +import * as fs from "fs/promises" + +const SCENARIO_ROOT_PREFIX = "roo-vscode-e2e-restart-" + +export type ScenarioWorkspace = { + root: string + workspace: string + userData: string + extensions: string + results: string +} + +function childPath(root: string, name: string): string { + const resolvedRoot = path.resolve(root) + const resolvedChild = path.resolve(resolvedRoot, name) + if (path.dirname(resolvedChild) !== resolvedRoot) { + throw new Error(`Scenario path escaped its root: ${name}`) + } + return resolvedChild +} + +export async function createScenarioWorkspace(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), SCENARIO_ROOT_PREFIX)) + const scenarioWorkspace: ScenarioWorkspace = { + root, + workspace: childPath(root, "workspace"), + userData: childPath(root, "user-data"), + extensions: childPath(root, "extensions"), + results: childPath(root, "results"), + } + + await Promise.all( + [ + scenarioPath(scenarioWorkspace, "workspace"), + scenarioPath(scenarioWorkspace, "userData"), + scenarioPath(scenarioWorkspace, "extensions"), + scenarioPath(scenarioWorkspace, "results"), + ].map((directory) => fs.mkdir(directory, { recursive: true })), + ) + + return scenarioWorkspace +} + +function scenarioPath(scenarioWorkspace: ScenarioWorkspace, key: "workspace" | "userData" | "extensions" | "results") { + return scenarioWorkspace[key] +} + +export async function removeScenarioWorkspace(scenarioWorkspace: ScenarioWorkspace): Promise { + const root = path.resolve(scenarioWorkspace.root) + const tempRoot = path.resolve(os.tmpdir()) + if (path.dirname(root) !== tempRoot || !path.basename(root).startsWith(SCENARIO_ROOT_PREFIX)) { + throw new Error(`Refusing to remove an unowned scenario root: ${scenarioWorkspace.root}`) + } + + await fs.rm(root, { recursive: true, force: true }) +} diff --git a/apps/vscode-e2e/src/restart/vscodeCoordinator.ts b/apps/vscode-e2e/src/restart/vscodeCoordinator.ts new file mode 100644 index 0000000000..9400aaaaf4 --- /dev/null +++ b/apps/vscode-e2e/src/restart/vscodeCoordinator.ts @@ -0,0 +1,90 @@ +import { spawn } from "child_process" +import * as path from "path" + +import { readPhaseResult, type RestartPhase, type PhaseResult } from "./phaseProtocol" +import type { ScenarioWorkspace } from "./scenarioWorkspace" + +export type RestartCoordinatorOptions = { + vscodeExecutablePath: string + extensionDevelopmentPath: string + extensionTestsPath: string + scenario: string + workspace: ScenarioWorkspace + environment: NodeJS.ProcessEnv + expectedExitPolicies: readonly ExpectedExitPolicy[] +} + +export type ExpectedExitPolicy = { + phase: RestartPhase + termination: "graceful-quit" + code: number + signal: NodeJS.Signals | null +} + +function phaseEnvironment(options: RestartCoordinatorOptions, phase: RestartPhase): NodeJS.ProcessEnv { + return { + ...options.environment, + E2E_PHASE: phase, + E2E_SCENARIO: options.scenario, + E2E_RESULTS_DIR: options.workspace.results, + } +} + +function phaseArguments(options: RestartCoordinatorOptions): string[] { + return [ + options.workspace.workspace, + `--user-data-dir=${options.workspace.userData}`, + `--extensions-dir=${options.workspace.extensions}`, + "--no-sandbox", + "--disable-gpu-sandbox", + "--disable-updates", + "--skip-welcome", + "--skip-release-notes", + "--disable-workspace-trust", + `--extensionTestsPath=${options.extensionTestsPath}`, + `--extensionDevelopmentPath=${options.extensionDevelopmentPath}`, + ] +} + +async function runPhase(options: RestartCoordinatorOptions, phase: RestartPhase): Promise { + const args = phaseArguments(options) + console.log(`[restart:${phase}] spawning VS Code: ${path.basename(options.vscodeExecutablePath)}`) + + const child = spawn(options.vscodeExecutablePath, args, { + env: phaseEnvironment(options, phase), + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }) + + child.stdout?.on("data", (chunk: Buffer) => process.stdout.write(`[restart:${phase}] ${chunk}`)) + child.stderr?.on("data", (chunk: Buffer) => process.stderr.write(`[restart:${phase}] ${chunk}`)) + + const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once("error", reject) + child.once("close", (code, signal) => resolve({ code, signal })) + }) + + const result = await readPhaseResult(options.workspace.results, phase) + if (result.status !== "passed") { + throw new Error(`[restart:${phase}] phase result was not passed: ${result.error?.message ?? "unknown failure"}`) + } + + const expectedExit = options.expectedExitPolicies.find((policy) => policy.phase === phase) + const isExpectedNonzeroExit = + exit.code !== 0 && + expectedExit !== undefined && + expectedExit.termination === "graceful-quit" && + exit.code === expectedExit.code && + exit.signal === expectedExit.signal + const isSuccessfulExit = exit.code === 0 && exit.signal === null + if (!isSuccessfulExit && !isExpectedNonzeroExit) { + throw new Error(`[restart:${phase}] VS Code exited with ${exit.code ?? exit.signal}`) + } + + return result +} + +export async function runRestartScenario(options: RestartCoordinatorOptions): Promise { + await runPhase(options, "create") + await runPhase(options, "verify") +} diff --git a/apps/vscode-e2e/src/runTest.ts b/apps/vscode-e2e/src/runTest.ts index 482e73e945..8162f34068 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -3,7 +3,7 @@ import * as os from "os" import * as fs from "fs/promises" import { readFileSync } from "fs" -import { runTests } from "@vscode/test-electron" +import { downloadAndUnzipVSCode, runTests } from "@vscode/test-electron" import { LLMock } from "@copilotkit/aimock" import { addApplyDiffResultFixtures } from "./fixtures/apply-diff" @@ -21,6 +21,8 @@ import { addSearchFilesResultFixtures } from "./fixtures/search-files" import { addSubtaskFixtures } from "./fixtures/subtasks" import { addUseMcpToolResultFixtures } from "./fixtures/use-mcp-tool" import { addWriteToFileResultFixtures } from "./fixtures/write-to-file" +import { createScenarioWorkspace, removeScenarioWorkspace } from "./restart/scenarioWorkspace" +import { runRestartScenario } from "./restart/vscodeCoordinator" function getCliFlagValue(flag: string) { return process.argv.find((arg, index) => process.argv[index - 1] === flag) @@ -43,6 +45,14 @@ function isBedrockTargetedRun(testFile?: string, testGrep?: string) { return testGrep?.toLowerCase().includes("bedrock") ?? false } +function isRestartPersistenceTargetedRun(testFile?: string, testGrep?: string): boolean { + if (testFile?.toLowerCase().includes("restart-persistence")) { + return true + } + + return testGrep?.toLowerCase().includes("restart persistence") ?? false +} + async function main() { const isRecord = process.env.AIMOCK_RECORD === "true" const testGrep = getCliFlagValue("--grep") || process.env.TEST_GREP @@ -50,6 +60,7 @@ async function main() { const isDeepSeekTest = isDeepSeekTargetedRun(testFile, testGrep) const isGeminiTest = testFile?.toLowerCase().includes("gemini.test") ?? false const isBedrockTest = isBedrockTargetedRun(testFile, testGrep) + const isRestartPersistenceTest = isRestartPersistenceTargetedRun(testFile, testGrep) if (isRecord && isDeepSeekTest && !process.env.DEEPSEEK_API_KEY) { throw new Error("AIMOCK_RECORD=true requires DEEPSEEK_API_KEY to record DeepSeek fixtures") @@ -83,11 +94,14 @@ async function main() { const extensionTestsPath = path.resolve(__dirname, "./suite/index") let testWorkspace: string | undefined + let scenarioWorkspace: Awaited> | undefined try { - // Create a temporary workspace folder for tests before installing fixtures that - // need workspace-specific paths. - testWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-workspace-")) + // Create a temporary workspace folder for regular tests. Restart scenarios own + // all of their paths under the dedicated scenario root below. + if (!isRestartPersistenceTest) { + testWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-workspace-")) + } if (useMock) { const fixturesDir = path.resolve(__dirname, "../fixtures") @@ -173,13 +187,36 @@ async function main() { const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "../package.json"), "utf-8")) const vscodeVersion = process.env.VSCODE_VERSION || pkg.devDependencies["@types/vscode"] - await runTests({ - extensionDevelopmentPath, - extensionTestsPath, - launchArgs: [testWorkspace], - extensionTestsEnv, - version: vscodeVersion, - }) + if (isRestartPersistenceTest) { + scenarioWorkspace = await createScenarioWorkspace() + const vscodeExecutablePath = await downloadAndUnzipVSCode({ + version: vscodeVersion, + extensionDevelopmentPath, + }) + await runRestartScenario({ + vscodeExecutablePath, + extensionDevelopmentPath, + extensionTestsPath, + scenario: "restart-persistence", + workspace: scenarioWorkspace, + environment: extensionTestsEnv, + expectedExitPolicies: [ + { phase: "create", termination: "graceful-quit", code: 1, signal: null }, + { phase: "verify", termination: "graceful-quit", code: 1, signal: null }, + ], + }) + } else { + if (!testWorkspace) { + throw new Error("Regular E2E runs require a temporary test workspace") + } + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [testWorkspace], + extensionTestsEnv, + version: vscodeVersion, + }) + } } catch (error) { console.error("Failed to run tests", error) process.exitCode = 1 @@ -187,6 +224,9 @@ async function main() { if (testWorkspace) { await fs.rm(testWorkspace, { recursive: true, force: true }) } + if (scenarioWorkspace) { + await removeScenarioWorkspace(scenarioWorkspace) + } await mock?.stop() } } diff --git a/apps/vscode-e2e/src/suite/index.ts b/apps/vscode-e2e/src/suite/index.ts index 63d29ec28c..e93d73bd37 100644 --- a/apps/vscode-e2e/src/suite/index.ts +++ b/apps/vscode-e2e/src/suite/index.ts @@ -72,7 +72,7 @@ export async function run() { testFiles = await glob(`**/${specificFile}`, { cwd }) console.log(`Running specific test file: ${specificFile}`) } else { - testFiles = await glob("**/**.test.js", { cwd }) + testFiles = await glob("**/**.test.js", { cwd, ignore: "**/suite/restart-persistence.test.js" }) } if (testFiles.length === 0) { diff --git a/apps/vscode-e2e/src/suite/restart-persistence.test.ts b/apps/vscode-e2e/src/suite/restart-persistence.test.ts new file mode 100644 index 0000000000..29e7fa3ddd --- /dev/null +++ b/apps/vscode-e2e/src/suite/restart-persistence.test.ts @@ -0,0 +1,119 @@ +import * as assert from "assert" +import * as vscode from "vscode" + +import { RooCodeEventName, type RooCodeAPI } from "@roo-code/types" + +import { + PHASE_RESULT_VERSION, + readPhaseResult, + serializePhaseError, + type PhaseResult, + writePhaseResult, +} from "../restart/phaseProtocol" +import { waitFor, waitUntilCompleted } from "./utils" + +const SCENARIO = "restart-persistence" +const MARKER = "RESTART_PERSISTENCE_MARKER" + +function getResultsDir(): string { + const resultsDir = process.env.E2E_RESULTS_DIR + if (!resultsDir) throw new Error("E2E_RESULTS_DIR is required") + return resultsDir +} + +async function quitGracefully(): Promise { + await vscode.commands.executeCommand("workbench.action.quit") +} + +async function runCreate(api: RooCodeAPI): Promise { + let taskId: string | undefined + let createPhasePassed = false + let sawMarker = false + const messageHandler = ({ message }: { message: { type: string; text?: string; partial?: boolean } }) => { + if (message.type === "say" && message.partial === false && message.text?.includes(MARKER)) { + sawMarker = true + } + } + api.on(RooCodeEventName.Message, messageHandler) + + try { + taskId = await api.startNewTask({ + configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }, + text: `${SCENARIO}: RESTART_PERSISTENCE_SMOKE`, + }) + await waitUntilCompleted({ api, taskId }) + assert.strictEqual(sawMarker, true, `Completion should include ${MARKER}`) + const historyItem = await api.getTaskHistoryItem(taskId) + assert.ok(historyItem, "Completed task should have a history item") + assert.ok(historyItem.task.includes("RESTART_PERSISTENCE_SMOKE"), "History title should include the marker") + const conversationLength = await api.getTaskApiConversationHistoryLength(taskId) + assert.ok(conversationLength > 0, "Completed task should persist API conversation history") + + const result: PhaseResult = { + version: PHASE_RESULT_VERSION, + phase: "create", + status: "passed", + values: { taskId }, + } + await writePhaseResult(getResultsDir(), result) + createPhasePassed = true + await quitGracefully() + } catch (error) { + await writePhaseResult(getResultsDir(), { + version: PHASE_RESULT_VERSION, + phase: "create", + status: "failed", + error: serializePhaseError(error), + }) + throw error + } finally { + api.off(RooCodeEventName.Message, messageHandler) + if (!createPhasePassed && taskId && api.getCurrentTaskStack().includes(taskId)) await api.cancelCurrentTask() + } +} + +async function runVerify(api: RooCodeAPI): Promise { + try { + const createResult = await readPhaseResult(getResultsDir(), "create") + assert.strictEqual(createResult.status, "passed") + const taskId = createResult.values?.taskId + assert.ok(taskId, "Create phase should record a task ID") + + await waitFor(() => api.isReady()) + assert.strictEqual(await api.isTaskInHistory(taskId), true, "Task should be present after restart") + const historyItem = await api.getTaskHistoryItem(taskId) + assert.ok(historyItem, "Task history item should be available after restart") + assert.ok(historyItem.task.includes("RESTART_PERSISTENCE_SMOKE"), "History title should persist after restart") + const conversationLength = await api.getTaskApiConversationHistoryLength(taskId) + assert.ok(conversationLength > 0, "API conversation history should be available after restart") + + await writePhaseResult(getResultsDir(), { + version: PHASE_RESULT_VERSION, + phase: "verify", + status: "passed", + values: { taskId, conversationLength: String(conversationLength) }, + }) + await quitGracefully() + } catch (error) { + await writePhaseResult(getResultsDir(), { + version: PHASE_RESULT_VERSION, + phase: "verify", + status: "failed", + error: serializePhaseError(error), + }) + throw error + } +} + +suite("Restart persistence", () => { + test("persists completed task across a fresh extension host", async () => { + const api = globalThis.api + if (process.env.E2E_PHASE === "create") { + await runCreate(api) + } else if (process.env.E2E_PHASE === "verify") { + await runVerify(api) + } else { + throw new Error(`Unknown E2E_PHASE: ${process.env.E2E_PHASE ?? "unset"}`) + } + }) +}) diff --git a/apps/vscode-e2e/src/suite/resume-eviction-race.test.ts b/apps/vscode-e2e/src/suite/resume-eviction-race.test.ts index 53f33cb4e5..d888978483 100644 --- a/apps/vscode-e2e/src/suite/resume-eviction-race.test.ts +++ b/apps/vscode-e2e/src/suite/resume-eviction-race.test.ts @@ -2,6 +2,7 @@ import * as assert from "assert" import { setDefaultSuiteTimeout } from "./test-utils" import { waitUntilCompleted, waitFor } from "./utils" +import { SCHED_COMPLETED_PROMPT } from "../fixtures/subtasks" // Regression test for the "Work #1 (no message)" title-clobber bug reported // against Zoo Code v3.76.0 (Discord, 2026-08-06). @@ -93,4 +94,53 @@ suite("Resume eviction race (title clobber regression)", function () { `Title must not change during resume eviction. Got: "${afterEviction.task}"`, ) }) + + test("concurrent resumes of one history item do not block the next task", async () => { + const api = globalThis.api + + // Persist a resumable history item, then remove its live instance so both + // resume calls begin from the same "not current" state. This mirrors two + // showTaskWithId messages arriving before either restoration has installed + // its replacement instance. + const historyTaskId = await waitUntilCompleted({ + api, + start: () => + api.startNewTask({ + configuration: { + mode: "ask", + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: SCHED_COMPLETED_PROMPT, + }), + }) + + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + + // Before the fix, both calls can construct and schedule distinct Task + // instances for historyTaskId. TaskRegistry.push() replaces the first map + // entry without disposing that instance, so its resume ask retains the + // scheduler's only permit while the visible replacement is queued. + await Promise.all([api.resumeTask(historyTaskId), api.resumeTask(historyTaskId)]) + await waitFor(() => api.getCurrentTaskStack().at(-1) === historyTaskId) + + // Starting a fresh task must evict the restored history task and acquire the + // scheduler permit. A leaked first restoration makes this wait time out. + const nextTaskId = await waitUntilCompleted({ + api, + start: () => + api.startNewTask({ + configuration: { + mode: "ask", + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: SCHED_COMPLETED_PROMPT, + }), + }) + + assert.notStrictEqual(nextTaskId, historyTaskId, "A fresh task should start after duplicate history resumes") + }) }) diff --git a/apps/vscode-e2e/src/theme-fixtures/definitions.ts b/apps/vscode-e2e/src/theme-fixtures/definitions.ts new file mode 100644 index 0000000000..a2359ef789 --- /dev/null +++ b/apps/vscode-e2e/src/theme-fixtures/definitions.ts @@ -0,0 +1,36 @@ +export const themeFixtureDefinitions = [ + { + name: "dark", + themeId: "Default Dark Modern", + bodyClass: "vscode-dark", + colorScheme: "dark", + kind: "dark", + }, + { + name: "light", + themeId: "Default Light Modern", + bodyClass: "vscode-light", + colorScheme: "light", + kind: "light", + }, + { + name: "high-contrast", + themeId: "Default High Contrast", + bodyClass: "vscode-high-contrast", + colorScheme: "dark", + kind: "high-contrast", + }, + { + name: "high-contrast-light", + themeId: "Default High Contrast Light", + bodyClass: "vscode-high-contrast-light", + colorScheme: "light", + kind: "high-contrast-light", + }, +] as const + +export type ThemeFixtureDefinition = (typeof themeFixtureDefinitions)[number] + +export function getThemeFixtureFileName(theme: ThemeFixtureDefinition): string { + return `vscode-theme-${theme.name}.css` +} diff --git a/apps/vscode-e2e/src/theme-fixtures/extension.ts b/apps/vscode-e2e/src/theme-fixtures/extension.ts new file mode 100644 index 0000000000..a6a026ca7a --- /dev/null +++ b/apps/vscode-e2e/src/theme-fixtures/extension.ts @@ -0,0 +1,79 @@ +import fs from "fs/promises" + +import * as vscode from "vscode" + +import type { RooCodeTestAPI, WebviewThemeFixture } from "@roo-code/types" + +import { themeFixtureDefinitions, type ThemeFixtureDefinition } from "./definitions" + +const POLL_INTERVAL_MS = 100 +const THEME_TIMEOUT_MS = 20_000 + +const colorThemeKinds: Record = { + dark: vscode.ColorThemeKind.Dark, + light: vscode.ColorThemeKind.Light, + "high-contrast": vscode.ColorThemeKind.HighContrast, + "high-contrast-light": vscode.ColorThemeKind.HighContrastLight, +} + +async function poll(capture: () => Promise, matches: (value: T) => boolean, description: string): Promise { + const deadline = Date.now() + THEME_TIMEOUT_MS + let latest: T | undefined + + while (Date.now() < deadline) { + latest = await capture() + if (matches(latest)) { + return latest + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + } + + throw new Error(`Timed out waiting for ${description}; latest value: ${JSON.stringify(latest)}`) +} + +function matchesTheme(fixture: WebviewThemeFixture, theme: ThemeFixtureDefinition): boolean { + return fixture.themeId === theme.themeId && fixture.bodyClass.split(/\s+/).includes(theme.bodyClass) +} + +export async function run(): Promise { + if (process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1") { + throw new Error("ROO_CODE_THEME_FIXTURE_PROBE must be set to 1") + } + + const outputPath = process.env.ROO_CODE_THEME_FIXTURE_CAPTURE_PATH + const expectedVersion = process.env.ROO_CODE_THEME_FIXTURE_VSCODE_VERSION + if (!outputPath || !expectedVersion) { + throw new Error("Theme fixture output path and VS Code version are required") + } + if (vscode.version !== expectedVersion) { + throw new Error(`Expected VS Code ${expectedVersion}, launched ${vscode.version}`) + } + + const extension = vscode.extensions.getExtension("ZooCodeOrganization.zoo-code") + if (!extension) { + throw new Error("Zoo Code extension not found") + } + const api = extension.isActive ? extension.exports : await extension.activate() + + await vscode.commands.executeCommand("zoo-code.SidebarProvider.focus") + await poll(async () => api.isReady(), Boolean, "Zoo Code webview activation") + + const captures: Record = {} + for (const theme of themeFixtureDefinitions) { + await vscode.workspace + .getConfiguration("workbench") + .update("colorTheme", theme.themeId, vscode.ConfigurationTarget.Global) + await poll( + async () => vscode.window.activeColorTheme.kind, + (kind) => kind === colorThemeKinds[theme.kind], + `${theme.themeId} color theme kind`, + ) + captures[theme.name] = await poll( + () => api.captureWebviewThemeFixture(), + (fixture) => matchesTheme(fixture, theme), + `${theme.themeId} webview identity`, + ) + } + + await fs.writeFile(outputPath, `${JSON.stringify(captures, null, 2)}\n`, "utf8") +} diff --git a/apps/vscode-e2e/src/theme-fixtures/fixtures.test.ts b/apps/vscode-e2e/src/theme-fixtures/fixtures.test.ts new file mode 100644 index 0000000000..16a66153f9 --- /dev/null +++ b/apps/vscode-e2e/src/theme-fixtures/fixtures.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import type { WebviewThemeFixture } from "@roo-code/types" + +import { themeFixtureDefinitions } from "./definitions" +import { createSerializedFixtures, findDriftedFixtures, serializeThemeFixture } from "./fixtures" + +const validVariables = Object.fromEntries( + Array.from({ length: 100 }, (_, index) => [`--vscode-test-${index}`, "#000000"]), +) +const validFixture: WebviewThemeFixture = { + themeId: "Default Dark Modern", + bodyClass: "vscode-dark", + variables: { + ...validVariables, + "--vscode-foreground": "#cccccc", + "--vscode-editor-background": "#1f1f1f", + "--vscode-button-foreground": "#ffffff", + }, +} + +test("serializeThemeFixture sorts variables and emits stable metadata", () => { + const fixture: WebviewThemeFixture = { + themeId: "Default Dark Modern", + bodyClass: "vscode-dark", + variables: { + "--vscode-z-last": "rgb(2, 2, 2)", + "--vscode-a-first": "#010101", + "--vscode-font-family": "platform-dependent", + }, + } + + assert.equal( + serializeThemeFixture(themeFixtureDefinitions[0], fixture, "1.100.0"), + [ + "/* Generated from Default Dark Modern by VS Code 1.100.0. Do not edit manually. */", + ".vscode-dark {", + "\tcolor-scheme: dark;", + "\t--vscode-a-first: #010101;", + "\t--vscode-z-last: rgb(2, 2, 2);", + "}", + "", + ].join("\n"), + ) +}) + +test("findDriftedFixtures reports missing and changed files in sorted order", () => { + const expected = new Map([ + ["vscode-theme-light.css", "light"], + ["vscode-theme-dark.css", "dark"], + ]) + const actual = new Map([["vscode-theme-light.css", "stale"]]) + + assert.deepEqual(findDriftedFixtures(expected, actual), ["vscode-theme-dark.css", "vscode-theme-light.css"]) +}) + +test("createSerializedFixtures rejects incomplete captures", () => { + const fixture: WebviewThemeFixture = { + ...validFixture, + variables: { + ...validVariables, + "--vscode-foreground": "#cccccc", + "--vscode-editor-background": "#1f1f1f", + }, + } + + assert.throws( + () => createSerializedFixtures(new Map([["dark", fixture]]), "1.100.0", [themeFixtureDefinitions[0]]), + /--vscode-button-foreground/, + ) +}) + +test("createSerializedFixtures rejects an empty capture", () => { + assert.throws( + () => + createSerializedFixtures(new Map([["dark", { ...validFixture, variables: {} }]]), "1.100.0", [ + themeFixtureDefinitions[0], + ]), + /fewer than 100/, + ) +}) + +test("createSerializedFixtures rejects the wrong theme identity", () => { + assert.throws( + () => + createSerializedFixtures( + new Map([["dark", { ...validFixture, themeId: "Default Light Modern" }]]), + "1.100.0", + [themeFixtureDefinitions[0]], + ), + /Expected Default Dark Modern/, + ) +}) diff --git a/apps/vscode-e2e/src/theme-fixtures/fixtures.ts b/apps/vscode-e2e/src/theme-fixtures/fixtures.ts new file mode 100644 index 0000000000..1cbb05a17f --- /dev/null +++ b/apps/vscode-e2e/src/theme-fixtures/fixtures.ts @@ -0,0 +1,77 @@ +import type { WebviewThemeFixture } from "@roo-code/types" + +import { getThemeFixtureFileName, type ThemeFixtureDefinition } from "./definitions" + +const requiredVariables = ["--vscode-foreground", "--vscode-editor-background", "--vscode-button-foreground"] +const minimumVariableCount = 100 +const environmentVariables = new Set(["--vscode-font-family", "--vscode-editor-font-family"]) + +export function validateThemeFixture(theme: ThemeFixtureDefinition, fixture: WebviewThemeFixture): void { + if (fixture.themeId !== theme.themeId) { + throw new Error(`Expected ${theme.themeId}, captured ${fixture.themeId || "an unknown theme"}`) + } + if (!fixture.bodyClass.split(/\s+/).includes(theme.bodyClass)) { + throw new Error(`Expected ${theme.bodyClass}, captured body classes: ${fixture.bodyClass || "none"}`) + } + if (Object.keys(fixture.variables).length < minimumVariableCount) { + throw new Error(`${theme.themeId} exposed fewer than ${minimumVariableCount} VS Code theme variables`) + } + for (const property of requiredVariables) { + if (!fixture.variables[property]) { + throw new Error(`${theme.themeId} did not expose required variable ${property}`) + } + } + for (const [property, value] of Object.entries(fixture.variables)) { + if (!property.startsWith("--vscode-") || !value) { + throw new Error(`${theme.themeId} exposed an invalid theme variable: ${property}`) + } + } +} + +export function serializeThemeFixture( + theme: ThemeFixtureDefinition, + fixture: WebviewThemeFixture, + vscodeVersion: string, +): string { + const declarations = Object.entries(fixture.variables) + .filter(([property]) => !environmentVariables.has(property)) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([property, value]) => `\t${property}: ${value};`) + + return [ + `/* Generated from ${theme.themeId} by VS Code ${vscodeVersion}. Do not edit manually. */`, + `.${theme.bodyClass} {`, + `\tcolor-scheme: ${theme.colorScheme};`, + ...declarations, + "}", + "", + ].join("\n") +} + +export function createSerializedFixtures( + captures: ReadonlyMap, + vscodeVersion: string, + themes: readonly ThemeFixtureDefinition[], +): Map { + return new Map( + themes.map((theme) => { + const capture = captures.get(theme.name) + if (!capture) { + throw new Error(`Missing captured theme fixture: ${theme.name}`) + } + validateThemeFixture(theme, capture) + + return [getThemeFixtureFileName(theme), serializeThemeFixture(theme, capture, vscodeVersion)] + }), + ) +} + +export function findDriftedFixtures( + expected: ReadonlyMap, + actual: ReadonlyMap, +): string[] { + return [...expected.entries()] + .filter(([fileName, contents]) => actual.get(fileName) !== contents) + .map(([fileName]) => fileName) + .sort() +} diff --git a/apps/vscode-e2e/src/theme-fixtures/generate.ts b/apps/vscode-e2e/src/theme-fixtures/generate.ts new file mode 100644 index 0000000000..60c1d9e8e7 --- /dev/null +++ b/apps/vscode-e2e/src/theme-fixtures/generate.ts @@ -0,0 +1,145 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { runTests } from "@vscode/test-electron" +import type { WebviewThemeFixture } from "@roo-code/types" + +import { themeFixtureDefinitions } from "./definitions" +import { createSerializedFixtures, findDriftedFixtures } from "./fixtures" + +type GeneratorMode = "update" | "check" + +async function readPinnedVSCodeVersion(packageRoot: string): Promise { + const packageJson = JSON.parse(await fs.readFile(path.join(packageRoot, "package.json"), "utf8")) as { + devDependencies?: Record + } + const version = packageJson.devDependencies?.["@types/vscode"] + if (!version) { + throw new Error("apps/vscode-e2e/package.json does not pin @types/vscode") + } + return version +} + +async function readTrackedFixtures( + directory: string, + fileNames: Iterable, +): Promise> { + return new Map( + await Promise.all( + [...fileNames].map(async (fileName) => { + try { + return [fileName, await fs.readFile(path.join(directory, fileName), "utf8")] as const + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return [fileName, undefined] as const + } + throw error + } + }), + ), + ) +} + +async function main(): Promise { + const mode = process.argv[2] as GeneratorMode | undefined + if (mode !== "update" && mode !== "check") { + throw new Error("Usage: generate ") + } + if (process.platform !== "linux") { + throw new Error("VS Code theme fixtures must be generated on the canonical Linux environment") + } + if (!process.env.DISPLAY) { + throw new Error(`Run theme fixture generation through "xvfb-run -a"`) + } + + const packageRoot = path.resolve(__dirname, "../..") + const repositoryRoot = path.resolve(packageRoot, "../..") + const extensionDevelopmentPath = path.join(repositoryRoot, "src") + const extensionTestsPath = path.resolve(__dirname, "extension") + const fixtureDirectory = path.join(repositoryRoot, "webview-ui", "playwright", "themes") + const vscodeVersion = await readPinnedVSCodeVersion(packageRoot) + const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-code-theme-fixtures-")) + const workspacePath = path.join(temporaryRoot, "workspace") + const userDataPath = path.join(temporaryRoot, "user-data") + const extensionsPath = path.join(temporaryRoot, "extensions") + const capturePath = path.join(temporaryRoot, "captures.json") + const generatedFixtureDirectory = path.join(temporaryRoot, "generated") + + try { + await Promise.all([ + fs.mkdir(workspacePath), + fs.mkdir(userDataPath), + fs.mkdir(extensionsPath), + fs.mkdir(generatedFixtureDirectory), + ]) + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + version: vscodeVersion, + launchArgs: [ + workspacePath, + `--user-data-dir=${userDataPath}`, + `--extensions-dir=${extensionsPath}`, + "--disable-workspace-trust", + "--skip-welcome", + "--skip-release-notes", + ], + extensionTestsEnv: { + ...process.env, + ROO_CODE_THEME_FIXTURE_PROBE: "1", + ROO_CODE_THEME_FIXTURE_CAPTURE_PATH: capturePath, + ROO_CODE_THEME_FIXTURE_VSCODE_VERSION: vscodeVersion, + }, + }) + + const captures = JSON.parse(await fs.readFile(capturePath, "utf8")) as Record + const generated = createSerializedFixtures( + new Map(Object.entries(captures)), + vscodeVersion, + themeFixtureDefinitions, + ) + await Promise.all( + [...generated].map(([fileName, contents]) => + fs.writeFile(path.join(generatedFixtureDirectory, fileName), contents, "utf8"), + ), + ) + + if (mode === "check") { + const [temporary, tracked] = await Promise.all([ + readTrackedFixtures(generatedFixtureDirectory, generated.keys()), + readTrackedFixtures(fixtureDirectory, generated.keys()), + ]) + const expected = new Map() + for (const [fileName, contents] of temporary) { + if (contents === undefined) { + throw new Error(`Temporary fixture was not generated: ${fileName}`) + } + expected.set(fileName, contents) + } + const drifted = findDriftedFixtures(expected, tracked) + if (drifted.length > 0) { + throw new Error( + `VS Code theme fixtures are out of date:\n${drifted.map((file) => `- ${file}`).join("\n")}`, + ) + } + console.log(`VS Code ${vscodeVersion} theme fixtures are current.`) + return + } + + await fs.mkdir(fixtureDirectory, { recursive: true }) + await Promise.all( + [...generated.keys()].map((fileName) => + fs.copyFile(path.join(generatedFixtureDirectory, fileName), path.join(fixtureDirectory, fileName)), + ), + ) + console.log(`Updated ${generated.size} theme fixtures from VS Code ${vscodeVersion}.`) + } finally { + await fs.rm(temporaryRoot, { recursive: true, force: true }) + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/codecov.yml b/codecov.yml index 7dd22dfdc2..0d45b50be0 100644 --- a/codecov.yml +++ b/codecov.yml @@ -42,6 +42,14 @@ flag_management: - packages/core/src/ carryforward: true +ignore: + # Playwright CT-only fixtures/helpers: exercised by the webview-ui-ct flag's browser run, not + # Vitest, and excluded from that flag's own lcov by playwright-ct.config.ts's sourceFilter (same + # ".visual." match). Without this, patch coverage sees 0% for these paths on any PR that adds or + # touches one, since no flag's lcov contains them. + - "webview-ui/src/**/*.visual.fixture.tsx" + - "webview-ui/src/**/*.visual.i18n.ts" + component_management: individual_components: - component_id: webview_components diff --git a/locales/ca/README.md b/locales/ca/README.md index 656679cc63..4cc35f4364 100644 --- a/locales/ca/README.md +++ b/locales/ca/README.md @@ -47,13 +47,13 @@ Zoo Code parteix de la base creada per Roo Code i continua ampliant-la amb: - **Fluxos de terminal i edició més fiables** — correccions per a la finalització prematura del terminal, les condicions de cursa en l'estat de les tasques, la gestió del context, l'edició de diff i l'ús d'eines específiques de cada proveïdor. - **Més control sobre el teu espai de treball** — gestió de regles, restriccions MCP per mode, controls de rutes multiarrel, opcions de raonament dels models i accions per revisar els canvis en completar una tasca. -## Novetats a la v3.76.0 +## Novetats a la v3.78.0 -- **Executa tasques més llargues i sense interrupcions amb Destructive Command Guard (DCG)** — DCG bloqueja les ordres perilloses mentre permet que Zoo continuï treballant sense que hagis de prémer contínuament botons d'aprovació, amb baixades i instal·lació reforçades del binari gestionat. -- **Millors controls i més fiabilitat dels proveïdors** — tria la velocitat de resposta d'OpenAI Codex, utilitza configuracions actualitzades de DeepSeek i gaudeix d'un aïllament més sòlid entre els canvis de perfil de proveïdor i les tasques en execució. -- **Correcció crítica de l'execució al terminal** — Zoo ara espera que les ordres del terminal acabin abans de començar el pas següent, cosa que evita treballs superposats i que el model continuï abans d'hora. -- L'agrupació més intel·ligent reuneix les aprovacions d'eines relacionades i manté separades les sol·licituds que no hi tenen relació. -- El lliurament de telemetria i l'obtenció de la memòria cau de models són més resistents davant d'errors i sol·licituds simultànies. +- **Han arribat tres grans models nous** — utilitza els nous Gemini 3.7 Flash, GLM 5.3 i Qwen3.8 Max, a més de millores en el raonament, els preus i la cobertura de proveïdors de DeepSeek V4. +- **Connecta't a NanoGPT** — utilitza el descobriment dinàmic de models, streaming i completions de prompts, i preferències d'encaminament per velocitat, preu, latència, rendiment, compatibilitat amb eines i memòria cau. +- **Proveïdors i tasques més fiables** — les correccions milloren la configuració dels endpoints d'Azure OpenAI, els límits de sortida de Kimi Code, la conservació dels títols de l'historial de tasques i la importació/exportació de configuració de Zoo. +- Destructive Command Guard ara és compatible amb els Mac basats en Intel. +- Les actualitzacions de seguretat solucionen vulnerabilitats a `undici` i Mermaid. ## Què pot fer Zoo Code per TU? diff --git a/locales/de/README.md b/locales/de/README.md index 033e3cf069..62626dd353 100644 --- a/locales/de/README.md +++ b/locales/de/README.md @@ -47,13 +47,13 @@ Zoo Code baut auf dem von Roo Code geschaffenen Fundament auf und erweitert es f - **Zuverlässigere Terminal- und Bearbeitungsabläufe** — Korrekturen für vorzeitige Terminalabschlüsse, Race Conditions beim Aufgabenstatus, Kontextverwaltung, diff-Bearbeitung und anbieterspezifische Tool-Nutzung. - **Mehr Kontrolle über deinen Workspace** — Regelverwaltung, MCP-Beschränkungen pro Modus, Pfadsteuerung für Multi-Root-Workspaces, Reasoning-Optionen für Modelle und Aktionen zur Prüfung von Änderungen nach Abschluss. -## Neu in v3.76.0 +## Neu in v3.78.0 -- **Längere, unterbrechungsfreie Aufgaben mit Destructive Command Guard (DCG)** — DCG blockiert gefährliche Befehle und lässt Zoo gleichzeitig weiterarbeiten, ohne dass du ständig Genehmigungen anklicken musst. Abgesicherte Downloads und Installationen der verwalteten Binärdatei sorgen dabei für zusätzliche Sicherheit. -- **Bessere Anbietersteuerung und Zuverlässigkeit** — wähle die Antwortgeschwindigkeit von OpenAI Codex, nutze aktualisierte DeepSeek-Konfigurationen und profitiere von einer stärkeren Isolierung zwischen Änderungen an Anbieterprofilen und laufenden Aufgaben. -- **Kritische Korrektur der Terminalausführung** — Zoo wartet jetzt, bis Terminalbefehle abgeschlossen sind, bevor der nächste Schritt beginnt. Dadurch werden sich überschneidende Arbeiten und ein vorzeitiges Fortfahren des Modells verhindert. -- Intelligentere Bündelung fasst Genehmigungen für zusammengehörige Tools zusammen und hält unabhängige Anfragen getrennt. -- Telemetrieübermittlung und das Abrufen des Modell-Caches sind bei Fehlern und gleichzeitigen Anfragen robuster. +- **Drei bedeutende neue Modelle sind da** — nutze die brandneuen Modelle Gemini 3.7 Flash, GLM 5.3 und Qwen3.8 Max sowie aktualisiertes Reasoning, Preise und Anbieterabdeckung für DeepSeek V4. +- **Verbinde dich mit NanoGPT** — nutze dynamische Modellerkennung, Streaming und Prompt-Vervollständigung sowie Routing-Einstellungen für Geschwindigkeit, Preis, Latenz, Durchsatz, Tool-Unterstützung und Caching. +- **Zuverlässigere Anbieter und Aufgaben** — Korrekturen verbessern die Einrichtung von Azure-OpenAI-Endpunkten, Kimi-Code-Ausgabelimits, die Beibehaltung von Titeln im Aufgabenverlauf sowie den Import und Export von Zoo-Einstellungen. +- Destructive Command Guard unterstützt jetzt Intel-basierte Macs. +- Sicherheitsupdates beheben Schwachstellen in `undici` und Mermaid. ## Was kann Zoo Code für DICH tun? diff --git a/locales/es/README.md b/locales/es/README.md index 13b5beb1bb..a7eabc790d 100644 --- a/locales/es/README.md +++ b/locales/es/README.md @@ -47,13 +47,13 @@ Zoo Code parte de los cimientos creados por Roo Code y continúa ampliándolos c - **Flujos de terminal y edición más fiables** — correcciones para la finalización prematura del terminal, las condiciones de carrera del estado de las tareas, la gestión del contexto, la edición de diff y el uso de herramientas específicas de cada proveedor. - **Más control sobre tu espacio de trabajo** — gestión de reglas, restricciones de MCP por modo, controles de rutas multirraíz, opciones de razonamiento de modelos y acciones para revisar los cambios al completar una tarea. -## Novedades de la v3.76.0 +## Novedades de la v3.78.0 -- **Ejecuta tareas más largas y sin interrupciones con Destructive Command Guard (DCG)** — DCG bloquea los comandos peligrosos mientras permite que Zoo siga trabajando sin que tengas que pulsar continuamente botones de aprobación, con descargas e instalación reforzadas del binario administrado. -- **Mejores controles y fiabilidad de los proveedores** — elige la velocidad de respuesta de OpenAI Codex, utiliza configuraciones actualizadas de DeepSeek y benefíciate de un aislamiento más sólido entre los cambios de perfiles de proveedor y las tareas en ejecución. -- **Corrección crítica de la ejecución en el terminal** — Zoo ahora espera a que los comandos del terminal terminen antes de iniciar el siguiente paso, lo que evita el trabajo superpuesto y que el modelo continúe antes de tiempo. -- La agrupación más inteligente reúne las aprobaciones de herramientas relacionadas y mantiene separadas las solicitudes que no tienen relación. -- La entrega de telemetría y la obtención de la caché de modelos son más resistentes ante fallos y solicitudes simultáneas. +- **Han llegado tres importantes modelos nuevos** — usa los flamantes Gemini 3.7 Flash, GLM 5.3 y Qwen3.8 Max, además de mejoras en el razonamiento, los precios y la cobertura de proveedores de DeepSeek V4. +- **Conéctate a NanoGPT** — usa descubrimiento dinámico de modelos, streaming y completado de prompts, y preferencias de enrutamiento por velocidad, precio, latencia, rendimiento, compatibilidad con herramientas y caché. +- **Proveedores y tareas más fiables** — las correcciones mejoran la configuración de endpoints de Azure OpenAI, los límites de salida de Kimi Code, la conservación de títulos del historial de tareas y la importación/exportación de ajustes de Zoo. +- Destructive Command Guard ahora es compatible con Macs basados en Intel. +- Las actualizaciones de seguridad corrigen vulnerabilidades en `undici` y Mermaid. ## ¿Qué puede hacer Zoo Code por TI? diff --git a/locales/fr/README.md b/locales/fr/README.md index bcc295e90e..b2bddceb55 100644 --- a/locales/fr/README.md +++ b/locales/fr/README.md @@ -47,13 +47,13 @@ Zoo Code s'appuie sur les fondations créées par Roo Code et continue de les en - **Des workflows de terminal et d'édition plus fiables** — correctifs pour les fins prématurées de commandes dans le terminal, les conditions de concurrence liées à l'état des tâches, la gestion du contexte, l'édition de diff et l'utilisation d'outils propres aux providers. - **Davantage de contrôle sur ton espace de travail** — gestion des règles, restrictions MCP par mode, contrôle des chemins multi-root, options de raisonnement des modèles et actions de vérification des modifications à la fin d'une tâche. -## Nouveautés de la v3.76.0 +## Nouveautés de la v3.78.0 -- **Exécute des tâches plus longues sans interruption avec Destructive Command Guard (DCG)** — DCG bloque les commandes dangereuses tout en laissant Zoo continuer à travailler sans que tu aies à cliquer constamment sur des boutons d'approbation, avec des téléchargements et une installation renforcés du binaire géré. -- **Meilleurs contrôles et fiabilité des providers** — choisis la vitesse de réponse d'OpenAI Codex, utilise les configurations DeepSeek mises à jour et profite d'une isolation renforcée entre les changements de profils de provider et les tâches en cours. -- **Correctif critique de l'exécution dans le terminal** — Zoo attend désormais que les commandes du terminal se terminent avant de commencer l'étape suivante, ce qui évite le chevauchement des opérations et la reprise prématurée du modèle. -- Un regroupement plus intelligent rassemble les approbations d'outils associés tout en séparant les demandes sans rapport. -- L'envoi de la télémétrie et la récupération du cache des modèles résistent mieux aux pannes et aux requêtes simultanées. +- **Trois nouveaux modèles majeurs sont arrivés** — utilise les tout nouveaux Gemini 3.7 Flash, GLM 5.3 et Qwen3.8 Max, ainsi que les améliorations du raisonnement, des tarifs et de la couverture des providers de DeepSeek V4. +- **Connecte-toi à NanoGPT** — profite de la découverte dynamique des modèles, du streaming et de la complétion des prompts, avec des préférences de routage pour la vitesse, le prix, la latence, le débit, la prise en charge des outils et le cache. +- **Providers et tâches plus fiables** — les correctifs améliorent la configuration des endpoints Azure OpenAI, les limites de sortie de Kimi Code, la conservation des titres dans l'historique des tâches et l'import/export des paramètres Zoo. +- Destructive Command Guard prend désormais en charge les Mac équipés d'un processeur Intel. +- Des mises à jour de sécurité corrigent des vulnérabilités dans `undici` et Mermaid. ## Que peut faire Zoo Code pour VOUS ? diff --git a/locales/hi/README.md b/locales/hi/README.md index 47ad3ae152..388ba7c525 100644 --- a/locales/hi/README.md +++ b/locales/hi/README.md @@ -46,13 +46,13 @@ Zoo Code, Roo Code की बनाई नींव पर आगे बढ़ - **ज़्यादा भरोसेमंद terminal और editing workflows** — terminal के समय से पहले पूरा होने, task-state race conditions, context management, diff editing और provider-specific tool use से जुड़ी समस्याओं के fixes। - **अपने workspace पर ज़्यादा control** — rules management, हर mode के लिए MCP restrictions, multi-root path controls, model reasoning options और completion changes की review actions। -## v3.76.0 में नया क्या है +## v3.78.0 में नया क्या है -- **Destructive Command Guard (DCG) के साथ लंबे और बिना रुकावट वाले tasks चलाओ** — DCG खतरनाक commands को block करता है और Zoo को लगातार approval buttons दबवाए बिना काम करते रहने देता है; managed binary downloads और installation को भी अधिक सुरक्षित बनाया गया है। -- **बेहतर provider controls और reliability** — OpenAI Codex की response speed चुनो, updated DeepSeek configurations इस्तेमाल करो और provider-profile changes व चल रहे tasks के बीच अधिक मज़बूत isolation का लाभ लो। -- **Terminal execution का अहम fix** — Zoo अब अगला step शुरू करने से पहले terminal commands के पूरा होने का इंतज़ार करता है, जिससे overlapping work और model का समय से पहले आगे बढ़ना रुकता है। -- Smarter batching संबंधित tool approvals को एक साथ रखती है और असंबंधित requests को अलग रखती है। -- Failures और concurrent requests के दौरान telemetry delivery और model-cache fetching अब अधिक भरोसेमंद हैं। +- **तीन बड़े नए models आ गए हैं** — बिल्कुल नए Gemini 3.7 Flash, GLM 5.3 और Qwen3.8 Max models का उपयोग करो, साथ ही DeepSeek V4 के updated reasoning, pricing और provider coverage का लाभ लो। +- **NanoGPT से connect करो** — dynamic model discovery, streaming और prompt completions के साथ speed, price, latency, throughput, tool support और caching के लिए routing preferences का उपयोग करो। +- **अधिक reliable providers और tasks** — fixes Azure OpenAI endpoint setup, Kimi Code output limits, task-history title preservation और Zoo settings import/export को बेहतर बनाते हैं। +- Destructive Command Guard अब Intel-आधारित Macs को support करता है। +- Security updates `undici` और Mermaid की vulnerabilities को ठीक करते हैं। ## Zoo Code आपके लिए क्या कर सकता है? diff --git a/locales/id/README.md b/locales/id/README.md index 772f64c1dd..794dc46dd1 100644 --- a/locales/id/README.md +++ b/locales/id/README.md @@ -46,13 +46,13 @@ Zoo Code dikembangkan di atas fondasi yang dibuat oleh Roo Code dan terus memper - **Workflow terminal dan pengeditan yang lebih andal** — perbaikan untuk terminal yang selesai terlalu dini, race condition status task, pengelolaan konteks, pengeditan diff, dan penggunaan tool khusus provider. - **Kontrol lebih besar atas workspace kamu** — pengelolaan rules, pembatasan MCP per mode, kontrol path multi-root, opsi reasoning model, dan tindakan untuk meninjau perubahan saat selesai. -## Yang Baru di v3.76.0 +## Yang Baru di v3.78.0 -- **Jalankan task yang lebih panjang tanpa gangguan dengan Destructive Command Guard (DCG)** — DCG memblokir perintah berbahaya sambil membiarkan Zoo terus bekerja tanpa kamu harus terus-menerus menekan tombol persetujuan, didukung download dan instalasi managed binary yang diperkuat. -- **Kontrol provider dan keandalan yang lebih baik** — pilih kecepatan respons OpenAI Codex, gunakan konfigurasi DeepSeek terbaru, dan dapatkan isolasi yang lebih kuat antara perubahan profil provider dan task yang sedang berjalan. -- **Perbaikan penting untuk eksekusi terminal** — Zoo kini menunggu perintah terminal selesai sebelum memulai langkah berikutnya, sehingga pekerjaan tidak saling tumpang tindih dan model tidak melanjutkan terlalu dini. -- Batching yang lebih cerdas mengelompokkan persetujuan tool terkait sambil tetap memisahkan permintaan yang tidak berkaitan. -- Pengiriman telemetri dan pengambilan cache model kini lebih tangguh saat terjadi kegagalan dan permintaan bersamaan. +- **Tiga model baru utama telah hadir** — gunakan model terbaru Gemini 3.7 Flash, GLM 5.3, dan Qwen3.8 Max, ditambah pembaruan reasoning, harga, dan cakupan provider DeepSeek V4. +- **Hubungkan ke NanoGPT** — gunakan penemuan model dinamis, streaming dan penyelesaian prompt, serta preferensi routing untuk kecepatan, harga, latensi, throughput, dukungan tool, dan caching. +- **Provider dan task yang lebih andal** — perbaikan meningkatkan pengaturan endpoint Azure OpenAI, batas output Kimi Code, penyimpanan judul riwayat task, serta impor/ekspor pengaturan Zoo. +- Destructive Command Guard kini mendukung Mac berbasis Intel. +- Pembaruan keamanan mengatasi kerentanan di `undici` dan Mermaid. ## Apa yang Bisa Zoo Code Lakukan Untuk ANDA? diff --git a/locales/it/README.md b/locales/it/README.md index c066da3456..7091aaca0c 100644 --- a/locales/it/README.md +++ b/locales/it/README.md @@ -47,13 +47,13 @@ Zoo Code parte dalle fondamenta create da Roo Code e continua ad ampliarle con: - **Workflow di terminale e modifica più affidabili** — correzioni per il completamento prematuro del terminale, le race condition dello stato delle attività, la gestione del contesto, la modifica dei diff e l'uso di strumenti specifici dei provider. - **Più controllo sul tuo workspace** — gestione delle regole, restrizioni MCP per modalità, controlli dei percorsi multi-root, opzioni di reasoning dei modelli e azioni per esaminare le modifiche al completamento. -## Novità in v3.76.0 +## Novità in v3.78.0 -- **Esegui attività più lunghe e senza interruzioni con Destructive Command Guard (DCG)** — DCG blocca i comandi pericolosi lasciando che Zoo continui a lavorare senza costringerti a premere continuamente i pulsanti di approvazione, con download e installazione rafforzati del binario gestito. -- **Controlli e affidabilità dei provider migliorati** — scegli la velocità di risposta di OpenAI Codex, usa le configurazioni DeepSeek aggiornate e approfitta di un isolamento più solido tra le modifiche ai profili provider e le attività in esecuzione. -- **Correzione critica dell'esecuzione nel terminale** — Zoo ora attende che i comandi del terminale terminino prima di iniziare il passaggio successivo, evitando sovrapposizioni di lavoro e la continuazione prematura del modello. -- Un raggruppamento più intelligente riunisce le approvazioni degli strumenti correlati mantenendo separate le richieste non correlate. -- L'invio della telemetria e il recupero della cache dei modelli sono più resilienti in caso di errori e richieste simultanee. +- **Sono arrivati tre importanti nuovi modelli** — usa i nuovissimi Gemini 3.7 Flash, GLM 5.3 e Qwen3.8 Max, oltre agli aggiornamenti di reasoning, prezzi e copertura dei provider per DeepSeek V4. +- **Connettiti a NanoGPT** — usa la scoperta dinamica dei modelli, streaming e completamenti dei prompt, con preferenze di routing per velocità, prezzo, latenza, throughput, supporto agli strumenti e caching. +- **Provider e task più affidabili** — le correzioni migliorano la configurazione degli endpoint Azure OpenAI, i limiti di output di Kimi Code, la conservazione dei titoli nella cronologia dei task e l'importazione/esportazione delle impostazioni Zoo. +- Destructive Command Guard ora supporta i Mac basati su Intel. +- Gli aggiornamenti di sicurezza risolvono vulnerabilità in `undici` e Mermaid. ## Cosa può fare Zoo Code per TE? diff --git a/locales/ja/README.md b/locales/ja/README.md index 36563af9af..bda6e078c2 100644 --- a/locales/ja/README.md +++ b/locales/ja/README.md @@ -46,13 +46,13 @@ Zoo Code は Roo Code が築いた基盤を引き継ぎ、次の機能で拡張 - **より信頼性の高いターミナルと編集ワークフロー** — ターミナルの早期完了、タスク状態の競合、コンテキスト管理、diff 編集、プロバイダー固有のツール利用に関する問題を修正。 - **ワークスペースをより細かく制御** — ルール管理、モードごとの MCP 制限、マルチルートのパス制御、モデルの reasoning オプション、完了時の変更レビュー操作を追加。 -## v3.76.0 の新機能 +## v3.78.0 の新機能 -- **Destructive Command Guard(DCG)で長時間のタスクを中断なく実行** — DCG が危険なコマンドをブロックし、承認ボタンを何度も押さなくても Zoo が作業を継続します。管理対象バイナリのダウンロードとインストールも強化されました。 -- **プロバイダーの制御性と信頼性を向上** — OpenAI Codex の応答速度を選択でき、更新された DeepSeek 設定を利用できます。プロバイダープロファイルの変更と実行中タスクの分離も強化されました。 -- **ターミナル実行の重要な修正** — Zoo はターミナルコマンドが完了するまで次のステップを開始しなくなり、作業の重複やモデルの早すぎる続行を防ぎます。 -- よりスマートなバッチ処理により、関連するツール承認をまとめながら、無関係なリクエストは分離します。 -- 障害発生時や同時リクエスト時でも、テレメトリ送信とモデルキャッシュ取得の安定性が向上しました。 +- **注目の新モデルが3つ登場** — 最新の Gemini 3.7 Flash、GLM 5.3、Qwen3.8 Max に加え、DeepSeek V4 の reasoning、価格、プロバイダー対応も更新されました。 +- **NanoGPT に接続** — 動的なモデル検出、ストリーミング、Prompt 補完に加え、速度、価格、レイテンシ、スループット、ツール対応、キャッシュに基づくルーティング設定を利用できます。 +- **プロバイダーとタスクの信頼性を向上** — Azure OpenAI エンドポイント設定、Kimi Code の出力上限、タスク履歴タイトルの保持、Zoo 設定のインポート/エクスポートを改善しました。 +- Destructive Command Guard が Intel 搭載 Mac に対応しました。 +- セキュリティアップデートにより `undici` と Mermaid の脆弱性を修正しました。 ## Zoo Codeがあなたのためにできること diff --git a/locales/ko/README.md b/locales/ko/README.md index 67e3547e2c..31d30f02c5 100644 --- a/locales/ko/README.md +++ b/locales/ko/README.md @@ -45,13 +45,13 @@ Zoo Code는 Roo Code가 만든 기반 위에서 다음 기능을 더하며 계 - **더 안정적인 터미널 및 편집 워크플로우** — 터미널 조기 완료, 작업 상태 경합, 컨텍스트 관리, diff 편집, 프로바이더별 도구 사용 문제를 수정. - **워크스페이스를 더 세밀하게 제어** — 규칙 관리, 모드별 MCP 제한, 멀티 루트 경로 제어, 모델 reasoning 옵션, 완료 시 변경 사항 검토 작업을 제공. -## v3.76.0의 새로운 기능 +## v3.78.0의 새로운 기능 -- **Destructive Command Guard(DCG)로 더 긴 작업을 중단 없이 실행** — DCG가 위험한 명령을 차단하는 동안 Zoo는 승인 버튼을 계속 누르지 않아도 작업을 이어가며, 관리형 바이너리의 다운로드와 설치도 강화됐어. -- **향상된 프로바이더 제어와 안정성** — OpenAI Codex 응답 속도를 선택하고, 업데이트된 DeepSeek 구성을 사용하며, 프로바이더 프로필 변경과 실행 중인 작업 사이의 더 강력한 격리를 활용할 수 있어. -- **중요한 터미널 실행 수정** — Zoo는 이제 터미널 명령이 끝날 때까지 기다린 후 다음 단계를 시작해서, 작업 중첩과 모델의 성급한 진행을 방지해. -- 더 스마트한 일괄 처리는 관련 도구 승인을 묶으면서 관련 없는 요청은 분리해. -- 장애와 동시 요청 상황에서도 텔레메트리 전송과 모델 캐시 가져오기가 더 안정적으로 동작해. +- **주요 신규 모델 3종이 출시되었습니다** — 완전히 새로운 Gemini 3.7 Flash, GLM 5.3, Qwen3.8 Max 모델과 업데이트된 DeepSeek V4 reasoning, 가격 및 프로바이더 지원을 사용해 보세요. +- **NanoGPT에 연결하세요** — 동적 모델 검색, 스트리밍 및 prompt completion과 함께 속도, 가격, 지연 시간, 처리량, 도구 지원, 캐싱을 기준으로 한 라우팅 설정을 사용할 수 있습니다. +- **더 안정적인 프로바이더와 작업** — Azure OpenAI endpoint 설정, Kimi Code 출력 제한, 작업 기록 제목 보존, Zoo 설정 가져오기/내보내기가 개선되었습니다. +- Destructive Command Guard가 이제 Intel 기반 Mac을 지원합니다. +- 보안 업데이트로 `undici`와 Mermaid의 취약점을 해결했습니다. ## Zoo Code가 당신을 위해 무엇을 할 수 있을까요? diff --git a/locales/nl/README.md b/locales/nl/README.md index a072400121..8af72a0103 100644 --- a/locales/nl/README.md +++ b/locales/nl/README.md @@ -47,13 +47,13 @@ Zoo Code bouwt voort op het fundament van Roo Code en breidt dit verder uit met: - **Betrouwbaardere terminal- en bewerkingsworkflows** — oplossingen voor voortijdige terminalvoltooiing, race conditions in taakstatussen, contextbeheer, diff-bewerking en providerspecifiek toolgebruik. - **Meer controle over je workspace** — regelbeheer, MCP-beperkingen per modus, padbeheer voor multi-root-workspaces, reasoning-opties voor modellen en acties om wijzigingen bij voltooiing te beoordelen. -## Nieuw in v3.76.0 +## Nieuw in v3.78.0 -- **Voer langere, ononderbroken taken uit met Destructive Command Guard (DCG)** — DCG blokkeert gevaarlijke opdrachten en laat Zoo ondertussen doorwerken zonder dat je steeds op goedkeuringsknoppen hoeft te drukken, ondersteund door beter beveiligde downloads en installatie van de beheerde binary. -- **Betere providerbediening en betrouwbaarheid** — kies de reactiesnelheid van OpenAI Codex, gebruik bijgewerkte DeepSeek-configuraties en profiteer van sterkere isolatie tussen wijzigingen aan providerprofielen en actieve taken. -- **Kritieke oplossing voor terminaluitvoering** — Zoo wacht nu tot terminalopdrachten zijn afgerond voordat de volgende stap begint, zodat werk niet overlapt en het model niet te vroeg doorgaat. -- Slimmere batching groepeert goedkeuringen voor gerelateerde tools en houdt niet-gerelateerde verzoeken gescheiden. -- Telemetrieverzending en het ophalen van de modelcache zijn beter bestand tegen fouten en gelijktijdige verzoeken. +- **Drie belangrijke nieuwe modellen zijn gearriveerd** — gebruik de gloednieuwe Gemini 3.7 Flash-, GLM 5.3- en Qwen3.8 Max-modellen, plus bijgewerkte reasoning, prijzen en providerondersteuning voor DeepSeek V4. +- **Maak verbinding met NanoGPT** — gebruik dynamische modeldetectie, streaming en promptaanvulling, plus routeringsvoorkeuren voor snelheid, prijs, latentie, doorvoer, toolondersteuning en caching. +- **Betrouwbaardere providers en taken** — oplossingen verbeteren de instelling van Azure OpenAI-endpoints, Kimi Code-uitvoerlimieten, het behouden van titels in de taakgeschiedenis en het importeren/exporteren van Zoo-instellingen. +- Destructive Command Guard ondersteunt nu Intel-gebaseerde Macs. +- Beveiligingsupdates verhelpen kwetsbaarheden in `undici` en Mermaid. ## Wat kan Zoo Code voor JOU doen? diff --git a/locales/pl/README.md b/locales/pl/README.md index 78e3ea9049..7a6133d4e9 100644 --- a/locales/pl/README.md +++ b/locales/pl/README.md @@ -45,13 +45,13 @@ Zoo Code rozwija fundament stworzony przez Roo Code i stale rozszerza go o: - **Bardziej niezawodne workflow terminala i edycji** — poprawki przedwczesnego kończenia poleceń terminala, race condition stanu zadań, zarządzania kontekstem, edycji diff i użycia narzędzi właściwych dla providerów. - **Większą kontrolę nad workspace** — zarządzanie regułami, ograniczenia MCP dla poszczególnych trybów, kontrolę ścieżek multi-root, opcje reasoning modeli i akcje przeglądu zmian po ukończeniu. -## Nowości w v3.76.0 +## Nowości w v3.78.0 -- **Uruchamiaj dłuższe, nieprzerywane zadania z Destructive Command Guard (DCG)** — DCG blokuje niebezpieczne polecenia, a Zoo może kontynuować pracę bez ciągłego klikania przycisków zatwierdzania. Pobieranie i instalacja zarządzanego pliku binarnego zostały dodatkowo zabezpieczone. -- **Lepsze sterowanie providerami i większa niezawodność** — wybieraj szybkość odpowiedzi OpenAI Codex, korzystaj ze zaktualizowanych konfiguracji DeepSeek i z mocniejszej izolacji między zmianami profili providerów a działającymi zadaniami. -- **Krytyczna poprawka wykonywania poleceń terminala** — Zoo czeka teraz na zakończenie poleceń terminala przed rozpoczęciem kolejnego kroku, co zapobiega nakładaniu się pracy i przedwczesnemu kontynuowaniu przez model. -- Inteligentniejsze grupowanie łączy zatwierdzenia powiązanych narzędzi, pozostawiając niepowiązane żądania osobno. -- Przesyłanie telemetrii i pobieranie pamięci podręcznej modeli są bardziej odporne na błędy i równoczesne żądania. +- **Pojawiły się trzy ważne nowe modele** — korzystaj z zupełnie nowych Gemini 3.7 Flash, GLM 5.3 i Qwen3.8 Max oraz zaktualizowanego reasoning, cen i obsługi providerów dla DeepSeek V4. +- **Połącz się z NanoGPT** — korzystaj z dynamicznego wykrywania modeli, streamingu i uzupełniania promptów oraz preferencji routingu według szybkości, ceny, opóźnienia, przepustowości, obsługi narzędzi i cache. +- **Bardziej niezawodni providerzy i zadania** — poprawki usprawniają konfigurację endpointów Azure OpenAI, limity wyjścia Kimi Code, zachowywanie tytułów historii zadań oraz import/eksport ustawień Zoo. +- Destructive Command Guard obsługuje teraz komputery Mac z procesorami Intel. +- Aktualizacje zabezpieczeń usuwają luki w `undici` i Mermaid. ## Co Zoo Code może zrobić dla CIEBIE? diff --git a/locales/pt-BR/README.md b/locales/pt-BR/README.md index d605af8551..dfdd18c4e8 100644 --- a/locales/pt-BR/README.md +++ b/locales/pt-BR/README.md @@ -47,13 +47,13 @@ O Zoo Code aproveita a base criada pelo Roo Code e continua ampliando-a com: - **Workflows de terminal e edição mais confiáveis** — correções para encerramento prematuro do terminal, race conditions no estado das tarefas, gerenciamento de contexto, edição de diff e uso de ferramentas específicas de cada provider. - **Mais controle sobre seu workspace** — gerenciamento de regras, restrições de MCP por modo, controles de caminhos multi-root, opções de reasoning dos modelos e ações para revisar alterações ao concluir uma tarefa. -## Novidades na v3.76.0 +## Novidades na v3.78.0 -- **Execute tarefas mais longas e sem interrupções com o Destructive Command Guard (DCG)** — o DCG bloqueia comandos perigosos enquanto permite que o Zoo continue trabalhando sem você precisar apertar botões de aprovação o tempo todo, com downloads e instalação reforçados do binário gerenciado. -- **Melhores controles e confiabilidade dos providers** — escolha a velocidade de resposta do OpenAI Codex, use configurações atualizadas do DeepSeek e conte com um isolamento mais forte entre alterações nos perfis de provider e tarefas em execução. -- **Correção crítica na execução do terminal** — agora o Zoo espera os comandos do terminal terminarem antes de iniciar a próxima etapa, evitando trabalho sobreposto e a continuação prematura do modelo. -- Um agrupamento mais inteligente reúne aprovações de ferramentas relacionadas e mantém solicitações não relacionadas separadas. -- O envio de telemetria e a busca do cache de modelos estão mais resilientes a falhas e solicitações simultâneas. +- **Três novos modelos importantes chegaram** — use os novíssimos Gemini 3.7 Flash, GLM 5.3 e Qwen3.8 Max, além das atualizações de reasoning, preços e cobertura de providers do DeepSeek V4. +- **Conecte-se ao NanoGPT** — use descoberta dinâmica de modelos, streaming e conclusão de prompts, com preferências de roteamento por velocidade, preço, latência, throughput, suporte a ferramentas e cache. +- **Providers e tarefas mais confiáveis** — as correções melhoram a configuração de endpoints do Azure OpenAI, os limites de saída do Kimi Code, a preservação de títulos no histórico de tarefas e a importação/exportação das configurações do Zoo. +- O Destructive Command Guard agora oferece suporte a Macs com processadores Intel. +- Atualizações de segurança corrigem vulnerabilidades no `undici` e no Mermaid. ## O que o Zoo Code pode fazer por VOCÊ? diff --git a/locales/ru/README.md b/locales/ru/README.md index d7ac72f622..e5e6d66198 100644 --- a/locales/ru/README.md +++ b/locales/ru/README.md @@ -47,13 +47,13 @@ Zoo Code развивает основу, созданную Roo Code, и про - **Более надёжные рабочие процессы в терминале и редакторе** — исправления преждевременного завершения команд, гонок состояния задач, управления контекстом, редактирования diff и использования инструментов отдельных провайдеров. - **Больше контроля над рабочей областью** — управление правилами, ограничения MCP для отдельных режимов, управление путями в многоуровневых рабочих областях, параметры reasoning моделей и действия для проверки изменений после завершения. -## Что нового в v3.76.0 +## Что нового в v3.78.0 -- **Выполняй более длительные задачи без перерывов с Destructive Command Guard (DCG)** — DCG блокирует опасные команды, позволяя Zoo продолжать работу без постоянного нажатия кнопок одобрения; загрузка и установка управляемого бинарного файла дополнительно защищены. -- **Улучшенное управление провайдерами и надёжность** — выбирай скорость ответа OpenAI Codex, используй обновлённые конфигурации DeepSeek и более строгую изоляцию изменений профилей провайдеров от выполняющихся задач. -- **Критическое исправление выполнения команд в терминале** — теперь Zoo ждёт завершения команд перед переходом к следующему шагу, предотвращая наложение работ и преждевременное продолжение модели. -- Более умная группировка объединяет одобрения связанных инструментов, сохраняя несвязанные запросы раздельными. -- Отправка телеметрии и получение кеша моделей стали устойчивее к сбоям и одновременным запросам. +- **Появились три важных новых модели** — используй совершенно новые Gemini 3.7 Flash, GLM 5.3 и Qwen3.8 Max, а также обновлённые reasoning, цены и поддержку провайдеров для DeepSeek V4. +- **Подключайся к NanoGPT** — используй динамическое обнаружение моделей, streaming и дополнение Prompt, а также настройки маршрутизации по скорости, цене, задержке, пропускной способности, поддержке инструментов и кешированию. +- **Более надёжные провайдеры и задачи** — исправления улучшают настройку endpoint Azure OpenAI, лимиты вывода Kimi Code, сохранение заголовков истории задач и импорт/экспорт настроек Zoo. +- Destructive Command Guard теперь поддерживает Mac на базе Intel. +- Обновления безопасности устраняют уязвимости в `undici` и Mermaid. ## Что Zoo Code может сделать для ВАС? diff --git a/locales/tr/README.md b/locales/tr/README.md index 537270b37d..5f90729546 100644 --- a/locales/tr/README.md +++ b/locales/tr/README.md @@ -47,13 +47,13 @@ Zoo Code, Roo Code'un oluşturduğu temel üzerine inşa ediliyor ve bu temeli - **Daha güvenilir terminal ve düzenleme iş akışları** — terminalin erken tamamlanması, görev durumu yarış koşulları, bağlam yönetimi, diff düzenleme ve sağlayıcıya özel araç kullanımı için düzeltmeler. - **Çalışma alanın üzerinde daha fazla kontrol** — kural yönetimi, mod başına MCP kısıtlamaları, çok köklü yol denetimleri, model reasoning seçenekleri ve tamamlanan değişiklikleri inceleme eylemleri. -## v3.76.0'daki Yenilikler +## v3.78.0'daki Yenilikler -- **Destructive Command Guard (DCG) ile daha uzun ve kesintisiz görevler çalıştır** — DCG tehlikeli komutları engellerken Zoo'nun sürekli onay düğmelerine basmana gerek kalmadan çalışmayı sürdürmesini sağlar; yönetilen ikili dosyanın indirilmesi ve kurulumu da güçlendirildi. -- **Daha iyi sağlayıcı denetimleri ve güvenilirlik** — OpenAI Codex yanıt hızını seç, güncellenmiş DeepSeek yapılandırmalarını kullan ve sağlayıcı profili değişiklikleriyle çalışan görevler arasındaki daha güçlü yalıtımdan yararlan. -- **Kritik terminal yürütme düzeltmesi** — Zoo artık sonraki adıma başlamadan önce terminal komutlarının bitmesini bekliyor; böylece işler çakışmıyor ve model erken devam etmiyor. -- Daha akıllı gruplama, ilişkili araç onaylarını bir araya getirirken ilgisiz istekleri ayrı tutuyor. -- Telemetri teslimi ve model önbelleğini getirme işlemleri, hatalar ve eş zamanlı istekler karşısında daha dayanıklı. +- **Üç önemli yeni model geldi** — yepyeni Gemini 3.7 Flash, GLM 5.3 ve Qwen3.8 Max modellerini, ayrıca güncellenmiş DeepSeek V4 reasoning, fiyatlandırma ve sağlayıcı kapsamını kullan. +- **NanoGPT'ye bağlan** — dinamik model keşfi, streaming ve Prompt tamamlama ile hız, fiyat, gecikme, throughput, araç desteği ve caching için yönlendirme tercihlerini kullan. +- **Daha güvenilir sağlayıcılar ve görevler** — düzeltmeler Azure OpenAI endpoint kurulumunu, Kimi Code çıktı sınırlarını, görev geçmişi başlıklarının korunmasını ve Zoo ayarlarının içe/dışa aktarımını iyileştiriyor. +- Destructive Command Guard artık Intel tabanlı Mac'leri destekliyor. +- Güvenlik güncellemeleri `undici` ve Mermaid'deki güvenlik açıklarını gideriyor. ## Zoo Code SİZİN İçin Ne Yapabilir? diff --git a/locales/vi/README.md b/locales/vi/README.md index 9ad3fed482..f2a95db23d 100644 --- a/locales/vi/README.md +++ b/locales/vi/README.md @@ -46,13 +46,13 @@ Zoo Code phát triển trên nền tảng do Roo Code tạo ra và tiếp tục - **Workflow terminal và chỉnh sửa đáng tin cậy hơn** — sửa lỗi terminal hoàn tất quá sớm, xung đột trạng thái tác vụ, quản lý ngữ cảnh, chỉnh sửa diff và sử dụng công cụ riêng của từng provider. - **Kiểm soát workspace tốt hơn** — quản lý quy tắc, giới hạn MCP theo từng chế độ, kiểm soát đường dẫn multi-root, tùy chọn reasoning của model và thao tác xem lại thay đổi khi hoàn tất. -## Điểm mới trong v3.76.0 +## Điểm mới trong v3.78.0 -- **Chạy tác vụ lâu hơn, không bị gián đoạn với Destructive Command Guard (DCG)** — DCG chặn các lệnh nguy hiểm trong khi vẫn để Zoo tiếp tục làm việc mà bạn không phải liên tục bấm nút phê duyệt, đồng thời tăng cường bảo mật cho việc tải xuống và cài đặt binary được quản lý. -- **Kiểm soát provider và độ tin cậy tốt hơn** — chọn tốc độ phản hồi của OpenAI Codex, dùng cấu hình DeepSeek đã cập nhật và hưởng lợi từ khả năng cách ly mạnh hơn giữa thay đổi hồ sơ provider với tác vụ đang chạy. -- **Bản sửa lỗi quan trọng cho việc chạy lệnh terminal** — Zoo giờ sẽ chờ lệnh terminal hoàn tất trước khi bắt đầu bước tiếp theo, ngăn công việc chồng chéo và model tiếp tục quá sớm. -- Cơ chế gom nhóm thông minh hơn sẽ nhóm các phê duyệt công cụ liên quan và giữ riêng những yêu cầu không liên quan. -- Việc gửi telemetry và tải cache model ổn định hơn khi có lỗi hoặc nhiều yêu cầu đồng thời. +- **Ba model mới quan trọng đã xuất hiện** — sử dụng các model hoàn toàn mới Gemini 3.7 Flash, GLM 5.3 và Qwen3.8 Max, cùng reasoning, giá và phạm vi provider được cập nhật cho DeepSeek V4. +- **Kết nối với NanoGPT** — sử dụng khám phá model động, streaming và hoàn thành Prompt, cùng tùy chọn định tuyến theo tốc độ, giá, độ trễ, throughput, hỗ trợ tool và caching. +- **Provider và task đáng tin cậy hơn** — các bản sửa lỗi cải thiện thiết lập endpoint Azure OpenAI, giới hạn đầu ra Kimi Code, giữ nguyên tiêu đề lịch sử task và nhập/xuất cài đặt Zoo. +- Destructive Command Guard hiện hỗ trợ máy Mac dùng chip Intel. +- Các bản cập nhật bảo mật khắc phục lỗ hổng trong `undici` và Mermaid. ## Zoo Code có thể làm gì cho BẠN? diff --git a/locales/zh-CN/README.md b/locales/zh-CN/README.md index 886b4123ff..4fba2e03de 100644 --- a/locales/zh-CN/README.md +++ b/locales/zh-CN/README.md @@ -43,13 +43,13 @@ Zoo Code 基于 Roo Code 打下的基础持续扩展,新增了: - **更可靠的终端和编辑工作流** — 修复终端过早完成、任务状态竞争、上下文管理、差异更新编辑和提供商专用工具调用等问题。 - **更全面的工作区控制** — 支持规则管理、按模式限制 MCP、多根工作区路径控制、模型推理选项和完成后的变更审查操作。 -## v3.76.0 新增内容 +## v3.78.0 新增内容 -- **通过 Destructive Command Guard (DCG) 长时间、不间断地运行任务** — DCG 会阻止危险命令,同时让 Zoo 继续工作,无需你反复点击批准按钮;托管二进制文件的下载和安装也经过了安全加固。 -- **更完善的提供商控制和可靠性** — 可选择 OpenAI Codex 响应速度、使用更新后的 DeepSeek 配置,并在提供商配置变更与运行中任务之间获得更强的隔离。 -- **重要的终端运行修复** — Zoo 现在会等待终端命令完成后再开始下一步,避免工作重叠和模型过早继续。 -- 更智能的批处理会合并相关工具的批准请求,同时将不相关的请求分开处理。 -- 遇到故障和并发请求时,遥测数据传输与模型缓存获取更加稳定可靠。 +- **三款重磅新模型现已推出** — 使用全新的 Gemini 3.7 Flash、GLM 5.3 和 Qwen3.8 Max 模型,以及更新后的 DeepSeek V4 推理、定价和提供商覆盖。 +- **连接 NanoGPT** — 使用动态模型发现、流式传输和 Prompt 补全,并按速度、价格、延迟、吞吐量、工具支持和缓存设置路由偏好。 +- **更可靠的提供商和任务** — 修复改进了 Azure OpenAI endpoint 设置、Kimi Code 输出限制、任务历史标题保留以及 Zoo 设置的导入/导出。 +- Destructive Command Guard 现在支持基于 Intel 的 Mac。 +- 安全更新修复了 `undici` 和 Mermaid 中的漏洞。 ## Zoo Code 能为您做什么? diff --git a/locales/zh-TW/README.md b/locales/zh-TW/README.md index c54bc3efc3..215a3886b6 100644 --- a/locales/zh-TW/README.md +++ b/locales/zh-TW/README.md @@ -43,13 +43,13 @@ Zoo Code 以 Roo Code 建立的基礎持續擴充,新增了: - **更可靠的終端機與編輯工作流程** — 修正終端機過早完成、工作狀態競爭、上下文管理、差異更新編輯和供應商專用工具使用等問題。 - **更完整的工作區控制** — 支援規則管理、依模式限制 MCP、多根工作區路徑控制、模型推理選項,以及完成後的變更檢閱操作。 -## v3.76.0 新功能 +## v3.78.0 新功能 -- **透過 Destructive Command Guard (DCG) 長時間、不間斷地執行工作** — DCG 會封鎖危險命令,同時讓 Zoo 繼續工作,不必一直按核准按鈕;受管理二進位檔的下載與安裝也經過安全強化。 -- **更完善的供應商控制與可靠性** — 可選擇 OpenAI Codex 回應速度、使用更新後的 DeepSeek 設定,並強化供應商設定檔變更與執行中工作之間的隔離。 -- **重要的終端機執行修正** — Zoo 現在會等待終端機命令完成後再開始下一步,避免工作重疊及模型過早繼續。 -- 更聰明的批次處理會合併相關工具的核准請求,同時將不相關的請求分開處理。 -- 發生錯誤或同時收到多個請求時,遙測資料傳送與模型快取擷取更加穩定可靠。 +- **三款重磅新模型現已推出** — 使用全新的 Gemini 3.7 Flash、GLM 5.3 和 Qwen3.8 Max 模型,以及更新後的 DeepSeek V4 推理、定價與供應商支援。 +- **連接 NanoGPT** — 使用動態模型探索、串流與 Prompt 補全,並依速度、價格、延遲、吞吐量、工具支援和快取設定路由偏好。 +- **更可靠的供應商與任務** — 修正改善了 Azure OpenAI endpoint 設定、Kimi Code 輸出限制、任務歷史標題保留,以及 Zoo 設定的匯入/匯出。 +- Destructive Command Guard 現在支援 Intel 架構的 Mac。 +- 安全性更新修正了 `undici` 與 Mermaid 中的漏洞。 ## Zoo Code 能為您做什麼? diff --git a/packages/types/src/__tests__/deepseek-v4-pro.test.ts b/packages/types/src/__tests__/deepseek-v4-pro.test.ts new file mode 100644 index 0000000000..4449bfd1f6 --- /dev/null +++ b/packages/types/src/__tests__/deepseek-v4-pro.test.ts @@ -0,0 +1,82 @@ +import { basetenModels, deepSeekModels, fireworksModels, opencodeGoModels } from "../providers/index.js" + +describe("DeepSeek V4 Pro 0813 provider catalogs", () => { + it.each([ + ["DeepSeek", deepSeekModels["deepseek-v4-pro"]], + ["OpenCode Go", opencodeGoModels["deepseek-v4-pro"]], + ])("labels the first-party API checkpoint through %s", (_provider, model) => { + expect(model).toBeDefined() + expect(model?.displayName).toBe("DeepSeek V4 Pro 0813") + expect(model?.contextWindow).toBeGreaterThanOrEqual(1_000_000) + }) + + it("uses peak first-party pricing and unchanged OpenCode Go pricing", () => { + expect(deepSeekModels["deepseek-v4-flash"]).toMatchObject({ + supportsImages: false, + outputPrice: 1.32, + cacheWritesPrice: 0.44, + cacheReadsPrice: 0.014, + }) + expect(deepSeekModels["deepseek-v4-pro"].supportsImages).toBe(false) + expect(deepSeekModels["deepseek-v4-pro"]).toMatchObject({ + outputPrice: 3.96, + cacheWritesPrice: 1.32, + cacheReadsPrice: 0.044, + }) + expect(opencodeGoModels["deepseek-v4-pro"]).toMatchObject({ + inputPrice: 0.435, + outputPrice: 0.87, + cacheReadsPrice: 0.003625, + }) + expect(opencodeGoModels["deepseek-v4-flash"]).toMatchObject({ + inputPrice: 0.14, + outputPrice: 0.28, + cacheReadsPrice: 0.0028, + }) + }) + + // Self-hosted providers retain separate IDs for the preview weights and 0813 checkpoint. + it.each([ + ["Fireworks AI", fireworksModels["accounts/fireworks/models/deepseek-v4-pro"]], + ["Baseten", basetenModels["deepseek-ai/DeepSeek-V4-Pro"]], + ])("does not apply the API checkpoint label to %s", (_provider, model) => { + expect(model).toBeDefined() + expect("displayName" in model && typeof model.displayName === "string" ? model.displayName : "").not.toContain( + "0813", + ) + expect(model?.contextWindow).toBeGreaterThanOrEqual(1_000_000) + }) + + it.each([ + [ + "Fireworks AI", + fireworksModels["accounts/fireworks/models/deepseek-v4-pro-0813"], + { inputPrice: 1.32, outputPrice: 3.96, cacheReadsPrice: 0.044 }, + ], + [ + "Baseten", + basetenModels["deepseek-ai/DeepSeek-V4-Pro-0813"], + { inputPrice: 1.32, outputPrice: 3.96, cacheReadsPrice: 0.132 }, + ], + ])("publishes the dated checkpoint and provider-specific pricing for %s", (_provider, model, pricing) => { + expect(model).toMatchObject({ + displayName: "DeepSeek V4 Pro 0813", + ...pricing, + }) + }) + + it("keeps preview pricing separate and omits unverified cache-write prices", () => { + expect(fireworksModels["accounts/fireworks/models/deepseek-v4-pro"]).toMatchObject({ + inputPrice: 1.74, + outputPrice: 3.48, + cacheReadsPrice: 0.145, + }) + expect(basetenModels["deepseek-ai/DeepSeek-V4-Pro"]).toMatchObject({ + inputPrice: 1.74, + outputPrice: 3.48, + cacheReadsPrice: 0.145, + }) + expect(basetenModels["deepseek-ai/DeepSeek-V4-Pro"]).not.toHaveProperty("cacheWritesPrice") + expect(basetenModels["deepseek-ai/DeepSeek-V4-Pro-0813"]).not.toHaveProperty("cacheWritesPrice") + }) +}) diff --git a/packages/types/src/__tests__/google-models.test.ts b/packages/types/src/__tests__/google-models.test.ts new file mode 100644 index 0000000000..80ee7c98ce --- /dev/null +++ b/packages/types/src/__tests__/google-models.test.ts @@ -0,0 +1,50 @@ +import { geminiModels } from "../providers/gemini.js" +import { vertexModels } from "../providers/vertex.js" + +describe.each([ + ["Gemini API", geminiModels["gemini-3.7-flash"]], + ["Vertex AI", vertexModels["gemini-3.7-flash"]], +])("Gemini 3.7 Flash on %s", (_provider, model) => { + it("exposes the supported thinking levels and introductory cache storage price", () => { + expect(model.supportsReasoningEffort).toEqual(["low", "medium", "high"]) + expect(model.cacheWritesPrice).toBe(0.5) + }) +}) + +describe.each([ + ["gemini-3.5-flash-lite", geminiModels["gemini-3.5-flash-lite"]], + ["gemini-3.1-flash-lite", geminiModels["gemini-3.1-flash-lite"]], +])("Gemini 3.x Flash Lite model %s", (_modelId, model) => { + it("is registered with the documented limits and multimodal support", () => { + expect(model.maxTokens).toBe(65_536) + expect(model.contextWindow).toBe(1_048_576) + expect(model.supportsImages).toBe(true) + expect(model.supportsPromptCache).toBe(true) + expect(model.supportsReasoningBudget).toBe(false) + }) +}) + +it("exposes the documented thinking levels and pricing for Gemini 3.5 Flash Lite", () => { + const model = geminiModels["gemini-3.5-flash-lite"] + expect(model.supportsReasoningEffort).toEqual(["minimal", "low", "medium", "high"]) + // The documented API default is On (minimal): + // https://ai.google.dev/gemini-api/docs/thinking + expect(model.reasoningEffort).toBe("minimal") + expect(model.inputPrice).toBe(0.3) + expect(model.outputPrice).toBe(2.5) + expect(model.cacheReadsPrice).toBe(0.03) + expect(model.cacheWritesPrice).toBe(1.0) +}) + +it("exposes the documented thinking levels and pricing for Gemini 3.1 Flash Lite", () => { + const model = geminiModels["gemini-3.1-flash-lite"] + // https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/3-1-flash-lite + // "choosing from minimal, low, medium, or high thinking levels" + expect(model.supportsReasoningEffort).toEqual(["minimal", "low", "medium", "high"]) + // Lowest supported level, matching the cheap/free Flash Lite tier. + expect(model.reasoningEffort).toBe("minimal") + expect(model.inputPrice).toBe(0.25) + expect(model.outputPrice).toBe(1.5) + expect(model.cacheReadsPrice).toBe(0.025) + expect(model.cacheWritesPrice).toBe(1.0) +}) diff --git a/packages/types/src/__tests__/lite-llm.test.ts b/packages/types/src/__tests__/lite-llm.test.ts index 74436c1848..87fdfdd0ad 100644 --- a/packages/types/src/__tests__/lite-llm.test.ts +++ b/packages/types/src/__tests__/lite-llm.test.ts @@ -20,7 +20,8 @@ describe("LiteLLM preserveReasoning model detection", () => { it("matches case-insensitively", () => { expect(isLiteLLMPreserveReasoningModel("MiniMax-M2.7-Highspeed")).toBe(true) - expect(isLiteLLMPreserveReasoningModel("GLM-5.2")).toBe(true) + expect(isLiteLLMPreserveReasoningModel("GLM-5.3")).toBe(true) + expect(isLiteLLMPreserveReasoningModel("QWEN3.8-MAX")).toBe(true) }) it("does not match model ids that merely contain a known family as a substring", () => { diff --git a/packages/types/src/__tests__/model-message-types.test.ts b/packages/types/src/__tests__/model-message-types.test.ts new file mode 100644 index 0000000000..453de78be1 --- /dev/null +++ b/packages/types/src/__tests__/model-message-types.test.ts @@ -0,0 +1,47 @@ +import { + OllamaModelsMessageType, + ollamaModelsMessageTypeSchema, + ollamaModelsMessageTypes, +} from "../providers/ollama.js" +import { OpenAiModelsMessageType, openAiModelsMessageTypeSchema } from "../providers/openai.js" +import { LmStudioModelsMessageType, lmStudioModelsMessageTypeSchema } from "../providers/lm-studio.js" +import { VsCodeLmModelsMessageType, vsCodeLmModelsMessageTypeSchema } from "../providers/vscode-llm.js" + +describe("OllamaModelsMessageType", () => { + it("exposes the request and response message types", () => { + expect(ollamaModelsMessageTypes).toEqual(["requestOllamaModels", "ollamaModels"]) + expect(OllamaModelsMessageType.requestOllamaModels).toBe("requestOllamaModels") + expect(OllamaModelsMessageType.ollamaModels).toBe("ollamaModels") + }) + + it("validates supported message types", () => { + expect(ollamaModelsMessageTypeSchema.safeParse("requestOllamaModels").success).toBe(true) + expect(ollamaModelsMessageTypeSchema.safeParse("ollamaModels").success).toBe(true) + expect(ollamaModelsMessageTypeSchema.safeParse("requestUnknownModels").success).toBe(false) + }) +}) + +describe.each([ + ["OpenAI", OpenAiModelsMessageType, openAiModelsMessageTypeSchema, "requestOpenAiModels", "openAiModels"], + [ + "LM Studio", + LmStudioModelsMessageType, + lmStudioModelsMessageTypeSchema, + "requestLmStudioModels", + "lmStudioModels", + ], + [ + "VS Code LM", + VsCodeLmModelsMessageType, + vsCodeLmModelsMessageTypeSchema, + "requestVsCodeLmModels", + "vsCodeLmModels", + ], +])("%s model message types", (_provider, messageType, schema, requestType, responseType) => { + it("exposes and validates its request and response types", () => { + expect(messageType).toMatchObject({ [requestType]: requestType, [responseType]: responseType }) + expect(schema.safeParse(requestType).success).toBe(true) + expect(schema.safeParse(responseType).success).toBe(true) + expect(schema.safeParse("unknownModelsMessage").success).toBe(false) + }) +}) diff --git a/packages/types/src/__tests__/nanogpt.test.ts b/packages/types/src/__tests__/nanogpt.test.ts new file mode 100644 index 0000000000..717d05ec59 --- /dev/null +++ b/packages/types/src/__tests__/nanogpt.test.ts @@ -0,0 +1,70 @@ +import { + applyNanoGptRoutingPreference, + dynamicProviders, + getModelId, + getProviderDefaultModelId, + isSecretStateKey, + nanoGptDefaultModelId, + nanoGptDefaultRoutingPreference, + providerIdentifiers, + providerSettingsSchema, +} from "../index.js" + +describe("NanoGPT shared contract", () => { + it("registers the stable dynamic-provider identity and default model", () => { + expect(providerIdentifiers.nanogpt).toBe("nanogpt") + expect(dynamicProviders).toContain(providerIdentifiers.nanogpt) + expect(getProviderDefaultModelId(providerIdentifiers.nanogpt)).toBe(nanoGptDefaultModelId) + }) + + it("classifies the API key as secret and resolves missing routing to auto", () => { + expect(isSecretStateKey("nanoGptApiKey")).toBe(true) + const settings = providerSettingsSchema.parse({ + apiProvider: providerIdentifiers.nanogpt, + nanoGptModelId: "model", + }) + expect(settings.nanoGptRoutingPreference ?? nanoGptDefaultRoutingPreference).toBe("auto") + expect(getModelId(settings)).toBe("model") + }) +}) + +describe("applyNanoGptRoutingPreference", () => { + it.each([ + ["auto", "model"], + ["fast", "model:fast"], + ["cheap", "model:cheap"], + ["latency", "model:latency"], + ["throughput", "model:throughput"], + ["tools", "model:tools"], + ["caching", "model"], + ] as const)("maps %s routing", (preference, expected) => { + expect(applyNanoGptRoutingPreference("model", preference)).toBe(expected) + }) + + it.each([ + "speed", + "fast", + "throughput", + "latency", + "price", + "cheap", + "floor", + "tools", + "caching", + "cache", + "cached", + ])("replaces the recognized %s routing alias", (alias) => { + expect(applyNanoGptRoutingPreference(`model:thinking:${alias}`, "cheap")).toBe("model:thinking:cheap") + expect(applyNanoGptRoutingPreference(`model:thinking:${alias}`, "auto")).toBe("model:thinking") + }) + + it("preserves legitimate identity suffixes", () => { + expect(applyNanoGptRoutingPreference("model:thinking", "fast")).toBe("model:thinking:fast") + expect(applyNanoGptRoutingPreference("model:thinking", "auto")).toBe("model:thinking") + }) + + it("normalizes multiple trailing routing suffixes to exactly one active preference", () => { + expect(applyNanoGptRoutingPreference("model:thinking:fast:cheap", "latency")).toBe("model:thinking:latency") + expect(applyNanoGptRoutingPreference("model:thinking:FAST:CACHED", "auto")).toBe("model:thinking") + }) +}) diff --git a/packages/types/src/__tests__/opencode-go.test.ts b/packages/types/src/__tests__/opencode-go.test.ts index 8079fe1bcb..adacbe274c 100644 --- a/packages/types/src/__tests__/opencode-go.test.ts +++ b/packages/types/src/__tests__/opencode-go.test.ts @@ -10,6 +10,7 @@ import { describe("opencode-go registry", () => { const anthropicFormatModels = [ + "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", @@ -21,6 +22,7 @@ describe("opencode-go registry", () => { "glm-5", "glm-5.1", "glm-5.2", + "glm-5.3", "kimi-k3", "kimi-k2.5", "kimi-k2.6", @@ -78,6 +80,38 @@ describe("opencode-go registry", () => { expect(info?.outputPrice).toBe(15.0) expect(info?.cacheReadsPrice).toBe(0.3) }) + + it("exposes current Qwen3.8 Max capabilities and Go pricing", () => { + const info = getOpencodeGoModelInfo("qwen3.8-max") + expect(info).toMatchObject({ + maxTokens: 131_072, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + supportsMaxTokens: true, + inputPrice: 2.0, + outputPrice: 6.0, + cacheReadsPrice: 0.25, + cacheWritesPrice: 2.5, + }) + expect(info?.preserveReasoning).toBeUndefined() + }) + + it("glm-5.3 exposes its native context, pricing, and always-on reasoning levels", () => { + const info = getOpencodeGoModelInfo("glm-5.3") + expect(info).toBeDefined() + expect(info?.maxTokens).toBe(131_072) + expect(info?.contextWindow).toBe(1_000_000) + expect(info?.supportsImages).toBe(false) + expect(info?.supportsPromptCache).toBe(true) + expect(info?.supportsMaxTokens).toBe(true) + expect(info?.supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(info?.reasoningEffort).toBe("max") + expect(info?.preserveReasoning).toBe(true) + expect(info?.inputPrice).toBe(1.4) + expect(info?.outputPrice).toBe(4.4) + expect(info?.cacheReadsPrice).toBe(0.26) + }) }) describe("OPENCODE_GO_ANTHROPIC_FORMAT_MODELS", () => { diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index 870ce77d78..29e55b31cf 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -32,6 +32,7 @@ const expectedProviderIdentifiers = [ "deepseek", "opencode-go", "kenari", + "nanogpt", "ollama", "lmstudio", "vscode-lm", @@ -106,6 +107,7 @@ describe("provider identifiers", () => { providerIdentifiers.moonshot, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, + providerIdentifiers.nanogpt, providerIdentifiers.kimiCode, ]) expect(localProviders).toEqual([providerIdentifiers.ollama, providerIdentifiers.lmstudio]) diff --git a/packages/types/src/__tests__/provider-model-id.test.ts b/packages/types/src/__tests__/provider-model-id.test.ts new file mode 100644 index 0000000000..7c404c1cd4 --- /dev/null +++ b/packages/types/src/__tests__/provider-model-id.test.ts @@ -0,0 +1,91 @@ +import { getModelId, modelIdKeys, providerIdentifiers, type ProviderSettings } from "../index.js" + +const expectedModelIdKeys = [ + "apiModelId", + "openRouterModelId", + "openAiModelId", + "ollamaModelId", + "lmStudioModelId", + "lmStudioDraftModelId", + "requestyModelId", + "unboundModelId", + "litellmModelId", + "vercelAiGatewayModelId", + "opencodeGoModelId", + "kenariModelId", + "nanoGptModelId", + "zooGatewayModelId", +] as const + +describe("modelIdKeys", () => { + it("preserves every model ID setting and its legacy precedence order", () => { + expect(modelIdKeys).toEqual(expectedModelIdKeys) + }) +}) + +describe("getModelId", () => { + it("uses a provider-specific model ID field instead of the shared apiModelId field", () => { + const settings: ProviderSettings = { + apiProvider: providerIdentifiers.openrouter, + apiModelId: "unrelated-model", + openRouterModelId: "openrouter-model", + } + + expect(getModelId(settings)).toBe("openrouter-model") + }) + + it("selects the active provider's field when multiple provider-specific model IDs are present", () => { + const settings: ProviderSettings = { + apiProvider: providerIdentifiers.ollama, + openRouterModelId: "inactive-openrouter-model", + ollamaModelId: "ollama-model", + } + + expect(getModelId(settings)).toBe("ollama-model") + }) + + it("uses the nested model selector for VS Code LM", () => { + const settings: ProviderSettings = { + apiProvider: providerIdentifiers.vscodeLm, + vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4o", id: "vscode-model", version: "1" }, + } + + expect(getModelId(settings)).toBe("vscode-model") + }) + + it("uses openAiModelId for OpenAI Compatible", () => { + const settings: ProviderSettings = { + apiProvider: providerIdentifiers.openai, + apiModelId: "unrelated-model", + openAiModelId: "openai-compatible-model", + } + + expect(getModelId(settings)).toBe("openai-compatible-model") + }) + + it.each([providerIdentifiers.openaiNative, providerIdentifiers.fakeAi])("uses apiModelId for %s", (apiProvider) => { + const settings: ProviderSettings = { apiProvider, apiModelId: "shared-model" } + + expect(getModelId(settings)).toBe("shared-model") + }) + + it("returns undefined when no provider is selected", () => { + expect(getModelId({})).toBeUndefined() + }) + + it("preserves legacy model ID precedence for retired providers", () => { + const settings: ProviderSettings = { + apiProvider: "groq", + lmStudioDraftModelId: "draft-model", + requestyModelId: "requesty-model", + } + + expect(getModelId(settings)).toBe("draft-model") + }) + + it("resolves a model ID for every provider definition without throwing", () => { + for (const apiProvider of Object.values(providerIdentifiers)) { + expect(() => getModelId({ apiProvider })).not.toThrow() + } + }) +}) diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index cd786a6529..b29a93ca3e 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -1,4 +1,4 @@ -import { ANTHROPIC_API_PROTOCOL, OPENAI_API_PROTOCOL, providerIdentifiers } from "../index.js" +import { ANTHROPIC_API_PROTOCOL, OPENAI_API_PROTOCOL, providerIdentifiers, providerNames } from "../index.js" import { getApiProtocol, OPEN_AI_CODEX_SERVICE_TIER_KEY, @@ -7,6 +7,20 @@ import { providerSettingsSchemaDiscriminated, } from "../provider-settings.js" import { OpenAiCodexServiceTier, OpenAiServiceTier } from "../model.js" +import { providerDefinitionList } from "../provider-settings/index.js" + +describe("provider settings discriminated union", () => { + it("composes exactly one provider-specific definition for every provider", () => { + const registeredProviders = providerDefinitionList.map(({ apiProvider }) => apiProvider) + + expect([...registeredProviders].sort()).toEqual([...providerNames].sort()) + expect(new Set(registeredProviders).size).toBe(providerNames.length) + }) + + it.each(providerNames)("accepts the %s provider branch", (apiProvider) => { + expect(providerSettingsSchemaDiscriminated.safeParse({ apiProvider }).success).toBe(true) + }) +}) describe("OpenAI Codex provider settings", () => { it("preserves the Fast preference in general and provider-specific schemas", () => { @@ -116,6 +130,7 @@ describe("getApiProtocol", () => { describe("Opencode Go provider", () => { it("should return 'anthropic' for opencode-go Anthropic-format models (Qwen/MiniMax)", () => { + expect(getApiProtocol(providerIdentifiers.opencodeGo, "qwen3.8-max")).toBe(ANTHROPIC_API_PROTOCOL) expect(getApiProtocol(providerIdentifiers.opencodeGo, "qwen3.7-max")).toBe(ANTHROPIC_API_PROTOCOL) expect(getApiProtocol(providerIdentifiers.opencodeGo, "qwen3.7-plus")).toBe(ANTHROPIC_API_PROTOCOL) expect(getApiProtocol(providerIdentifiers.opencodeGo, "qwen3.6-plus")).toBe(ANTHROPIC_API_PROTOCOL) @@ -125,7 +140,7 @@ describe("getApiProtocol", () => { }) it("should return 'openai' for opencode-go OpenAI-format models (GLM/DeepSeek/etc.)", () => { - expect(getApiProtocol(providerIdentifiers.opencodeGo, "glm-5.2")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.opencodeGo, "glm-5.3")).toBe(OPENAI_API_PROTOCOL) expect(getApiProtocol(providerIdentifiers.opencodeGo, "deepseek-v4-pro")).toBe(OPENAI_API_PROTOCOL) expect(getApiProtocol(providerIdentifiers.opencodeGo, "kimi-k2.5")).toBe(OPENAI_API_PROTOCOL) expect(getApiProtocol(providerIdentifiers.opencodeGo, "mimo-v2.5")).toBe(OPENAI_API_PROTOCOL) diff --git a/packages/types/src/__tests__/telemetry.isTelemetryOptedIn.test.ts b/packages/types/src/__tests__/telemetry.isTelemetryOptedIn.test.ts new file mode 100644 index 0000000000..671f88cc67 --- /dev/null +++ b/packages/types/src/__tests__/telemetry.isTelemetryOptedIn.test.ts @@ -0,0 +1,21 @@ +// pnpm --filter @roo-code/types test src/__tests__/telemetry.isTelemetryOptedIn.test.ts + +import { isTelemetryOptedIn } from "../telemetry.js" + +describe("isTelemetryOptedIn", () => { + it("returns true for an explicit 'enabled' setting", () => { + expect(isTelemetryOptedIn("enabled")).toBe(true) + }) + + it("returns false for an explicit 'disabled' setting", () => { + expect(isTelemetryOptedIn("disabled")).toBe(false) + }) + + it("returns true for 'unset' (disclosed opt-out default applies)", () => { + expect(isTelemetryOptedIn("unset")).toBe(true) + }) + + it("returns true for undefined (treated the same as unset)", () => { + expect(isTelemetryOptedIn(undefined)).toBe(true) + }) +}) diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 89e9c8bc2b..80808a274a 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -6,6 +6,7 @@ import type { RooCodeSettings } from "./global-settings.js" import type { HistoryItem } from "./history.js" import type { ProviderSettingsEntry, ProviderSettings } from "./provider-settings.js" import type { IpcMessage, IpcServerEvents } from "./ipc.js" +import type { WebviewThemeFixture } from "./vscode-extension-host.js" export type RooCodeAPIEvents = RooCodeEvents @@ -169,6 +170,10 @@ export interface RooCodeAPI extends EventEmitter { setTerminalProfile(name: string | undefined): void } +export interface RooCodeTestAPI extends RooCodeAPI { + captureWebviewThemeFixture(): Promise +} + export interface RooCodeIpcServer extends EventEmitter { listen(): void broadcast(message: IpcMessage): void diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index dc3ea072fd..bd440512ce 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -324,6 +324,7 @@ export const SECRET_STATE_KEYS = [ "vercelAiGatewayApiKey", "opencodeGoApiKey", "kenariApiKey", + "nanoGptApiKey", "basetenApiKey", ] as const diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 9fbf9e358b..cedf2280b3 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -186,3 +186,18 @@ export type ModelInfo = z.infer export type ModelRecord = Record export type RouterModels = Record + +export const routerModelsMessageTypes = [ + "flushRouterModels", + "requestRouterModels", + "routerModels", + "singleRouterModelFetchResponse", +] as const + +export const routerModelsMessageTypeSchema = z.enum(routerModelsMessageTypes) + +export const RouterModelsMessageType = routerModelsMessageTypeSchema.enum + +export type RouterModelsMessageType = z.infer + +export const allRouterModelsProvider = "all" as const diff --git a/packages/types/src/provider-identifiers.ts b/packages/types/src/provider-identifiers.ts index f231bc1ab1..fdf507bb6b 100644 --- a/packages/types/src/provider-identifiers.ts +++ b/packages/types/src/provider-identifiers.ts @@ -14,6 +14,7 @@ export const providerIdentifiers = { deepseek: "deepseek", opencodeGo: "opencode-go", kenari: "kenari", + nanogpt: "nanogpt", ollama: "ollama", lmstudio: "lmstudio", vscodeLm: "vscode-lm", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 99b75de2e4..4a432970c2 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -1,13 +1,21 @@ import { z } from "zod" -import { - modelInfoSchema, - openAiCodexServiceTierSchema, - reasoningEffortSettingSchema, - verbosityLevelsSchema, - serviceTierSchema, -} from "./model.js" +import { providerDefinitionList, type ProviderDefinition } from "./provider-settings/index.js" +import { API_PROVIDER_FIELD, SETTINGS_SHAPE_FIELD } from "./provider-settings/common.js" +export { + OPEN_AI_CODEX_SERVICE_TIER_KEY, + kimiCodeAuthMethodSchema, + type KimiCodeAuthMethod, + nanoGptDefaultRoutingPreference, + nanoGptRoutingPreferences, + nanoGptRoutingPreferenceSchema, + type NanoGptRoutingPreference, + zaiApiLineSchema, + type ZaiApiLine, +} from "./provider-settings/index.js" + import { codebaseIndexProviderSchema } from "./codebase-index.js" +import type { UnionToIntersection } from "./type-fu.js" import { providerIdentifiers, retiredProviderIdentifiers, @@ -44,7 +52,6 @@ import { */ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3 -export const OPEN_AI_CODEX_SERVICE_TIER_KEY = "openAiCodexServiceTier" /** * DynamicProvider @@ -64,6 +71,7 @@ export const dynamicProviders = [ providerIdentifiers.moonshot, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, + providerIdentifiers.nanogpt, providerIdentifiers.kimiCode, ] as const @@ -163,7 +171,7 @@ export type ProviderNameWithRetired = z.infer * ProviderSettings */ -const baseProviderSettingsSchema = z.object({ - includeMaxTokens: z.boolean().optional(), - todoListEnabled: z.boolean().optional(), - modelTemperature: z.number().nullish(), - rateLimitSeconds: z.number().optional(), - consecutiveMistakeLimit: z.number().min(0).optional(), - - // Model reasoning. - enableReasoningEffort: z.boolean().optional(), - reasoningEffort: reasoningEffortSettingSchema.optional(), - modelMaxTokens: z.number().optional(), - modelMaxThinkingTokens: z.number().optional(), - - // Model verbosity. - verbosity: verbosityLevelsSchema.optional(), -}) - -// Several of the providers share common model config properties. -const apiModelIdProviderModelSchema = baseProviderSettingsSchema.extend({ - apiModelId: z.string().optional(), -}) - -const anthropicSchema = apiModelIdProviderModelSchema.extend({ - apiKey: z.string().optional(), - anthropicBaseUrl: z.string().optional(), - anthropicUseAuthToken: z.boolean().optional(), - anthropicBeta1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. -}) - -const openRouterSchema = baseProviderSettingsSchema.extend({ - openRouterApiKey: z.string().optional(), - openRouterModelId: z.string().optional(), - openRouterBaseUrl: z.string().optional(), - openRouterSpecificProvider: z.string().optional(), -}) - -const bedrockSchema = apiModelIdProviderModelSchema.extend({ - awsAccessKey: z.string().optional(), - awsSecretKey: z.string().optional(), - awsSessionToken: z.string().optional(), - awsRegion: z.string().optional(), - awsUseCrossRegionInference: z.boolean().optional(), - awsUseGlobalInference: z.boolean().optional(), // Enable Global Inference profile routing when supported - awsUsePromptCache: z.boolean().optional(), - awsProfile: z.string().optional(), - awsUseProfile: z.boolean().optional(), - awsApiKey: z.string().optional(), - awsUseApiKey: z.boolean().optional(), - awsCustomArn: z.string().optional(), - awsModelContextWindow: z.number().optional(), - awsBedrockEndpointEnabled: z.boolean().optional(), - awsBedrockEndpoint: z.string().optional(), - awsBedrock1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. - awsBedrockServiceTier: z.enum(["STANDARD", "FLEX", "PRIORITY"]).optional(), // AWS Bedrock service tier selection -}) - -const vertexSchema = apiModelIdProviderModelSchema.extend({ - vertexKeyFile: z.string().optional(), - vertexJsonCredentials: z.string().optional(), - vertexProjectId: z.string().optional(), - vertexRegion: z.string().optional(), - vertex1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. -}) - -const openAiSchema = baseProviderSettingsSchema.extend({ - openAiBaseUrl: z.string().optional(), - openAiApiKey: z.string().optional(), - openAiR1FormatEnabled: z.boolean().optional(), - openAiModelId: z.string().optional(), - openAiCustomModelInfo: modelInfoSchema.nullish(), - openAiUseAzure: z.boolean().optional(), - azureApiVersion: z.string().optional(), - openAiStreamingEnabled: z.boolean().optional(), - openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration. - openAiHeaders: z.record(z.string(), z.string()).optional(), -}) - -const ollamaSchema = baseProviderSettingsSchema.extend({ - ollamaModelId: z.string().optional(), - ollamaBaseUrl: z.string().optional(), - ollamaApiKey: z.string().optional(), - ollamaNumCtx: z.number().int().min(128).optional(), -}) - -const vsCodeLmSchema = baseProviderSettingsSchema.extend({ - vsCodeLmModelSelector: z - .object({ - vendor: z.string().optional(), - family: z.string().optional(), - version: z.string().optional(), - id: z.string().optional(), - }) - .optional(), -}) - -const lmStudioSchema = baseProviderSettingsSchema.extend({ - lmStudioModelId: z.string().optional(), - lmStudioBaseUrl: z.string().optional(), - lmStudioDraftModelId: z.string().optional(), - lmStudioSpeculativeDecodingEnabled: z.boolean().optional(), -}) - -const geminiSchema = apiModelIdProviderModelSchema.extend({ - geminiApiKey: z.string().optional(), - googleGeminiBaseUrl: z.string().optional(), -}) - -const geminiCliSchema = apiModelIdProviderModelSchema.extend({ - geminiCliOAuthPath: z.string().optional(), - geminiCliProjectId: z.string().optional(), -}) - -const openAiCodexSchema = apiModelIdProviderModelSchema.extend({ - // Codex "Fast" mode maps to the Responses API priority service tier. - [OPEN_AI_CODEX_SERVICE_TIER_KEY]: openAiCodexServiceTierSchema.optional(), -}) - -const openAiNativeSchema = apiModelIdProviderModelSchema.extend({ - openAiNativeApiKey: z.string().optional(), - openAiNativeBaseUrl: z.string().optional(), - // OpenAI Responses API service tier for openai-native provider only. - // UI should only expose this when the selected model supports flex/priority. - openAiNativeServiceTier: serviceTierSchema.optional(), -}) - -const mistralSchema = apiModelIdProviderModelSchema.extend({ - mistralApiKey: z.string().optional(), - mistralCodestralUrl: z.string().optional(), -}) - -const deepSeekSchema = apiModelIdProviderModelSchema.extend({ - deepSeekBaseUrl: z.string().optional(), - deepSeekApiKey: z.string().optional(), -}) - -const poeSchema = apiModelIdProviderModelSchema.extend({ - poeApiKey: z.string().optional(), - poeBaseUrl: z.string().optional(), -}) - -const moonshotSchema = apiModelIdProviderModelSchema.extend({ - moonshotBaseUrl: z - .union([z.literal("https://api.moonshot.ai/v1"), z.literal("https://api.moonshot.cn/v1")]) - .optional(), - moonshotApiKey: z.string().optional(), -}) - -export const kimiCodeAuthMethodSchema = z.enum(["oauth", "api-key"]) -export type KimiCodeAuthMethod = z.infer - -const kimiCodeSchema = apiModelIdProviderModelSchema.extend({ - kimiCodeAuthMethod: kimiCodeAuthMethodSchema.optional(), - kimiCodeApiKey: z.string().optional(), -}) - -const minimaxSchema = apiModelIdProviderModelSchema.extend({ - minimaxBaseUrl: z - .union([z.literal("https://api.minimax.io/v1"), z.literal("https://api.minimaxi.com/v1")]) - .optional(), - minimaxApiKey: z.string().optional(), -}) - -const mimoSchema = apiModelIdProviderModelSchema.extend({ - mimoBaseUrl: z - .union([ - z.literal("https://api.xiaomimimo.com/v1"), - z.literal("https://token-plan-cn.xiaomimimo.com/v1"), - z.literal("https://token-plan-sgp.xiaomimimo.com/v1"), - z.literal("https://token-plan-ams.xiaomimimo.com/v1"), - ]) - .optional(), - mimoApiKey: z.string().optional(), -}) - -const requestySchema = baseProviderSettingsSchema.extend({ - requestyBaseUrl: z.string().optional(), - requestyApiKey: z.string().optional(), - requestyModelId: z.string().optional(), -}) - -const unboundSchema = baseProviderSettingsSchema.extend({ - unboundApiKey: z.string().optional(), - unboundModelId: z.string().optional(), -}) - -const fakeAiSchema = baseProviderSettingsSchema.extend({ - fakeAi: z.unknown().optional(), -}) - -const xaiSchema = apiModelIdProviderModelSchema.extend({ - xaiApiKey: z.string().optional(), -}) - -const litellmSchema = baseProviderSettingsSchema.extend({ - litellmBaseUrl: z.string().optional(), - litellmApiKey: z.string().optional(), - litellmModelId: z.string().optional(), - litellmUsePromptCache: z.boolean().optional(), -}) +type ListedProvider = (typeof providerDefinitionList)[number][typeof API_PROVIDER_FIELD] +const allProvidersAreDefined: Exclude extends never ? true : never = true +void allProvidersAreDefined -const sambaNovaSchema = apiModelIdProviderModelSchema.extend({ - sambaNovaApiKey: z.string().optional(), -}) +const indexProviderDefinitions = ( + definitions: readonly ProviderDefinition[], +): Partial> => { + const indexedDefinitions: Partial> = {} -export const zaiApiLineSchema = z.enum(["international_coding", "china_coding", "international_api", "china_api"]) + // Keep registry construction non-throwing so a malformed definition cannot prevent the extension from starting in production. + for (const definition of definitions) { + if (indexedDefinitions[definition.apiProvider]) { + console.warn(`Duplicate provider definition ignored: ${definition.apiProvider}`) + } -export type ZaiApiLine = z.infer + indexedDefinitions[definition.apiProvider] ??= definition + } -const zaiSchema = apiModelIdProviderModelSchema.extend({ - zaiApiKey: z.string().optional(), - zaiApiLine: zaiApiLineSchema.optional(), -}) + for (const provider of providerNames) { + if (!indexedDefinitions[provider]) { + console.warn(`Missing provider definition: ${provider}`) + } + } -const fireworksSchema = apiModelIdProviderModelSchema.extend({ - fireworksApiKey: z.string().optional(), -}) + return indexedDefinitions +} -const friendliSchema = apiModelIdProviderModelSchema.extend({ - friendliApiKey: z.string().optional(), -}) +const providerDefinitions = indexProviderDefinitions(providerDefinitionList) -const qwenCodeSchema = apiModelIdProviderModelSchema.extend({ - qwenCodeOauthPath: z.string().optional(), +const defaultSchema = z.object({ + [API_PROVIDER_FIELD]: z.undefined(), }) -const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({ - vercelAiGatewayApiKey: z.string().optional(), - vercelAiGatewayModelId: z.string().optional(), -}) +type ProviderDefinitionSchemas = { + [K in keyof D]: D[K]["schema"] +} -const opencodeGoSchema = baseProviderSettingsSchema.extend({ - opencodeGoApiKey: z.string().optional(), - opencodeGoModelId: z.string().optional(), -}) +const getDiscriminatedSchemas = ( + definitions: D, +): ProviderDefinitionSchemas => { + const [firstDefinition, ...remainingDefinitions] = definitions + // Array mapping widens the tuple, so restore the per-definition schema tuple type expected by Zod. + return [ + firstDefinition.schema, + ...remainingDefinitions.map((definition) => definition.schema), + ] as ProviderDefinitionSchemas +} -const kenariSchema = baseProviderSettingsSchema.extend({ - kenariApiKey: z.string().optional(), - kenariModelId: z.string().optional(), -}) +const providerDiscriminatedSchemas = getDiscriminatedSchemas(providerDefinitionList) -const zooGatewaySchema = baseProviderSettingsSchema.extend({ - zooSessionToken: z.string().optional(), - zooGatewayModelId: z.string().optional(), - zooGatewayBaseUrl: z.string().optional(), -}) +export const providerSettingsSchemaDiscriminated = z.discriminatedUnion(API_PROVIDER_FIELD, [ + ...providerDiscriminatedSchemas, + defaultSchema, +]) -const basetenSchema = apiModelIdProviderModelSchema.extend({ - basetenApiKey: z.string().optional(), -}) +type ProviderSettingsShape = UnionToIntersection<(typeof providerDefinitionList)[number][typeof SETTINGS_SHAPE_FIELD]> -const defaultSchema = z.object({ - apiProvider: z.undefined(), -}) +const providerSettingsObjectSchema = providerDefinitionList.reduce( + (schema, definition) => schema.merge(z.object(definition[SETTINGS_SHAPE_FIELD])), + z.object({}), +) -export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [ - anthropicSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.anthropic) })), - openRouterSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.openrouter) })), - bedrockSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.bedrock) })), - vertexSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.vertex) })), - openAiSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.openai) })), - ollamaSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.ollama) })), - vsCodeLmSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.vscodeLm) })), - lmStudioSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.lmstudio) })), - geminiSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.gemini) })), - geminiCliSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.geminiCli) })), - openAiCodexSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.openaiCodex) })), - openAiNativeSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.openaiNative) })), - mistralSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.mistral) })), - deepSeekSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.deepseek) })), - poeSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.poe) })), - moonshotSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.moonshot) })), - kimiCodeSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.kimiCode) })), - minimaxSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.minimax) })), - mimoSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.mimo) })), - requestySchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.requesty) })), - unboundSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.unbound) })), - fakeAiSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.fakeAi) })), - xaiSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.xai) })), - basetenSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.baseten) })), - litellmSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.litellm) })), - sambaNovaSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.sambanova) })), - zaiSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.zai) })), - fireworksSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.fireworks) })), - friendliSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.friendli) })), - qwenCodeSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.qwenCode) })), - vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.vercelAiGateway) })), - opencodeGoSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.opencodeGo) })), - kenariSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.kenari) })), - zooGatewaySchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.zooGateway) })), - defaultSchema, -]) +// `AnyZodObject.shape` loses the merged shape precision, so restore the type derived from the provider definitions. +const providerSettingsShape = providerSettingsObjectSchema.shape as ProviderSettingsShape export const providerSettingsSchema = z.object({ - apiProvider: providerNamesWithRetiredSchema.optional(), - ...anthropicSchema.shape, - ...openRouterSchema.shape, - ...bedrockSchema.shape, - ...vertexSchema.shape, - ...openAiSchema.shape, - ...ollamaSchema.shape, - ...vsCodeLmSchema.shape, - ...lmStudioSchema.shape, - ...geminiSchema.shape, - ...geminiCliSchema.shape, - ...openAiCodexSchema.shape, - ...openAiNativeSchema.shape, - ...mistralSchema.shape, - ...deepSeekSchema.shape, - ...poeSchema.shape, - ...moonshotSchema.shape, - ...kimiCodeSchema.shape, - ...minimaxSchema.shape, - ...mimoSchema.shape, - ...requestySchema.shape, - ...unboundSchema.shape, - ...fakeAiSchema.shape, - ...xaiSchema.shape, - ...basetenSchema.shape, - ...litellmSchema.shape, - ...sambaNovaSchema.shape, - ...zaiSchema.shape, - ...fireworksSchema.shape, - ...friendliSchema.shape, - ...qwenCodeSchema.shape, - ...vercelAiGatewaySchema.shape, - ...opencodeGoSchema.shape, - ...kenariSchema.shape, - ...zooGatewaySchema.shape, + [API_PROVIDER_FIELD]: providerNamesWithRetiredSchema.optional(), + ...providerSettingsShape, ...codebaseIndexProviderSchema.shape, }) @@ -517,9 +265,13 @@ export type ProviderSettingsWithId = z.infer +/** + * @deprecated Use `getModelId()` to resolve the model ID for the active provider. + */ export const modelIdKeys = [ "apiModelId", "openRouterModelId", @@ -533,57 +285,49 @@ export const modelIdKeys = [ "vercelAiGatewayModelId", "opencodeGoModelId", "kenariModelId", + "nanoGptModelId", "zooGatewayModelId", -] as const satisfies readonly (keyof ProviderSettings)[] - -export type ModelIdKey = (typeof modelIdKeys)[number] - -export const getModelId = (settings: ProviderSettings): string | undefined => { - const modelIdKey = modelIdKeys.find((key) => settings[key]) - return modelIdKey ? settings[modelIdKey] : undefined -} +] as const satisfies readonly ModelIdKey[] /** - * TypicalProvider + * @deprecated Provider categories should use the specific provider type guards. */ - export type TypicalProvider = Exclude +/** + * @deprecated Use the specific provider type guards instead. + */ export const isTypicalProvider = (key: unknown): key is TypicalProvider => isProviderName(key) && !isInternalProvider(key) && !isCustomProvider(key) && !isFauxProvider(key) -export const modelIdKeysByProvider: Record = { - [providerIdentifiers.anthropic]: "apiModelId", - [providerIdentifiers.openrouter]: "openRouterModelId", - [providerIdentifiers.bedrock]: "apiModelId", - [providerIdentifiers.vertex]: "apiModelId", - [providerIdentifiers.openaiCodex]: "apiModelId", - [providerIdentifiers.openaiNative]: "openAiModelId", - [providerIdentifiers.ollama]: "ollamaModelId", - [providerIdentifiers.lmstudio]: "lmStudioModelId", - [providerIdentifiers.gemini]: "apiModelId", - [providerIdentifiers.geminiCli]: "apiModelId", - [providerIdentifiers.mistral]: "apiModelId", - [providerIdentifiers.moonshot]: "apiModelId", - [providerIdentifiers.kimiCode]: "apiModelId", - [providerIdentifiers.minimax]: "apiModelId", - [providerIdentifiers.mimo]: "apiModelId", - [providerIdentifiers.deepseek]: "apiModelId", - [providerIdentifiers.poe]: "apiModelId", - [providerIdentifiers.qwenCode]: "apiModelId", - [providerIdentifiers.requesty]: "requestyModelId", - [providerIdentifiers.unbound]: "unboundModelId", - [providerIdentifiers.xai]: "apiModelId", - [providerIdentifiers.baseten]: "apiModelId", - [providerIdentifiers.litellm]: "litellmModelId", - [providerIdentifiers.sambanova]: "apiModelId", - [providerIdentifiers.zai]: "apiModelId", - [providerIdentifiers.fireworks]: "apiModelId", - [providerIdentifiers.friendli]: "apiModelId", - [providerIdentifiers.vercelAiGateway]: "vercelAiGatewayModelId", - [providerIdentifiers.opencodeGo]: "opencodeGoModelId", - [providerIdentifiers.kenari]: "kenariModelId", - [providerIdentifiers.zooGateway]: "zooGatewayModelId", +/** + * @deprecated Use `getModelId()` instead. This map is retained for API compatibility. + */ +export const modelIdKeysByProvider = Object.fromEntries( + providerDefinitionList.flatMap((definition) => { + if (!isTypicalProvider(definition.apiProvider)) { + return [] + } + + if (!definition.modelIdKey) { + throw new Error(`Missing model ID key for provider definition: ${definition.apiProvider}`) + } + + return [[definition.apiProvider, definition.modelIdKey] as const] + }), +) as Record + +export function getModelId(settings: ProviderSettings): string | undefined { + if (isProviderName(settings.apiProvider)) { + return providerDefinitions[settings.apiProvider]?.getModelId(settings) + } + + if (typeof settings.apiProvider === "string" && isRetiredProvider(settings.apiProvider)) { + const modelIdKey = modelIdKeys.find((key) => settings[key]) + return modelIdKey ? settings[modelIdKey] : undefined + } + + return undefined } /** @@ -655,6 +399,7 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str export const MODELS_BY_PROVIDER: Record< Exclude< ProviderName, + // OpenAI is custom-configured; Fake AI and Gemini CLI do not expose model lists. typeof providerIdentifiers.fakeAi | typeof providerIdentifiers.geminiCli | typeof providerIdentifiers.openai >, { id: ProviderName; label: string; models: string[] } @@ -769,6 +514,7 @@ export const MODELS_BY_PROVIDER: Record< }, [providerIdentifiers.opencodeGo]: { id: providerIdentifiers.opencodeGo, label: "Opencode Go", models: [] }, [providerIdentifiers.kenari]: { id: providerIdentifiers.kenari, label: "Kenari", models: [] }, + [providerIdentifiers.nanogpt]: { id: providerIdentifiers.nanogpt, label: "NanoGPT", models: [] }, [providerIdentifiers.zooGateway]: { id: providerIdentifiers.zooGateway, label: "Zoo Gateway", models: [] }, // Local providers; models discovered from localhost endpoints. diff --git a/packages/types/src/provider-settings/anthropic.ts b/packages/types/src/provider-settings/anthropic.ts new file mode 100644 index 0000000000..80ff6f15fb --- /dev/null +++ b/packages/types/src/provider-settings/anthropic.ts @@ -0,0 +1,22 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const anthropicProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.anthropic, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + apiKey: z.string().optional(), + anthropicBaseUrl: z.string().optional(), + anthropicUseAuthToken: z.boolean().optional(), + anthropicBeta1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. + }, +}) diff --git a/packages/types/src/provider-settings/baseten.ts b/packages/types/src/provider-settings/baseten.ts new file mode 100644 index 0000000000..ac9649af30 --- /dev/null +++ b/packages/types/src/provider-settings/baseten.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const basetenProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.baseten, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + basetenApiKey: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/bedrock.ts b/packages/types/src/provider-settings/bedrock.ts new file mode 100644 index 0000000000..0456c7c232 --- /dev/null +++ b/packages/types/src/provider-settings/bedrock.ts @@ -0,0 +1,35 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const bedrockProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.bedrock, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + awsAccessKey: z.string().optional(), + awsSecretKey: z.string().optional(), + awsSessionToken: z.string().optional(), + awsRegion: z.string().optional(), + awsUseCrossRegionInference: z.boolean().optional(), + awsUseGlobalInference: z.boolean().optional(), // Enable Global Inference profile routing when supported + awsUsePromptCache: z.boolean().optional(), + awsProfile: z.string().optional(), + awsUseProfile: z.boolean().optional(), + awsApiKey: z.string().optional(), + awsUseApiKey: z.boolean().optional(), + awsCustomArn: z.string().optional(), + awsModelContextWindow: z.number().optional(), + awsBedrockEndpointEnabled: z.boolean().optional(), + awsBedrockEndpoint: z.string().optional(), + awsBedrock1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. + awsBedrockServiceTier: z.enum(["STANDARD", "FLEX", "PRIORITY"]).optional(), // AWS Bedrock service tier selection + }, +}) diff --git a/packages/types/src/provider-settings/common.ts b/packages/types/src/provider-settings/common.ts new file mode 100644 index 0000000000..e73a05f143 --- /dev/null +++ b/packages/types/src/provider-settings/common.ts @@ -0,0 +1,76 @@ +import { z } from "zod" + +import { reasoningEffortSettingSchema, verbosityLevelsSchema } from "../model.js" +import type { ProviderIdentifier } from "../provider-identifiers.js" + +export const API_PROVIDER_FIELD = "apiProvider" +export const SETTINGS_SHAPE_FIELD = "settingsShape" +export const API_MODEL_ID_FIELD = "apiModelId" + +export const baseProviderSettingsShape = { + includeMaxTokens: z.boolean().optional(), + todoListEnabled: z.boolean().optional(), + modelTemperature: z.number().nullish(), + rateLimitSeconds: z.number().optional(), + consecutiveMistakeLimit: z.number().min(0).optional(), + enableReasoningEffort: z.boolean().optional(), + reasoningEffort: reasoningEffortSettingSchema.optional(), + modelMaxTokens: z.number().optional(), + modelMaxThinkingTokens: z.number().optional(), + verbosity: verbosityLevelsSchema.optional(), +} + +export const apiModelIdProviderModelShape = { + ...baseProviderSettingsShape, + [API_MODEL_ID_FIELD]: z.string().optional(), +} + +type ModelId = string | undefined +type UntypedProviderSettings = Record +type ProviderModelIdAccessor = (settings: UntypedProviderSettings) => ModelId +type ProviderSettingsFromSchema = z.infer> +type TypedProviderModelIdAccessor = (settings: ProviderSettingsFromSchema) => ModelId + +type ProviderDefinitionInput

= { + apiProvider: P + schema: S + modelIdKey?: Extract + getModelId: TypedProviderModelIdAccessor +} + +export type ProviderDefinition = { + apiProvider: ProviderIdentifier + settingsShape: z.ZodRawShape + modelIdKey?: string + schema: z.ZodDiscriminatedUnionOption + getModelId: ProviderModelIdAccessor +} + +export const createModelIdAccessor = + (modelIdKey: string): ProviderModelIdAccessor => + (settings) => + settings[modelIdKey] as ModelId + +// `modelIdKey` supports deprecated exports. Remove it in favor of an accessor-only contract when those exports are removed. +export const createProviderDefinition =

({ + apiProvider, + schema, + ...modelIdDefinition +}: ProviderDefinitionInput) => { + const settingsSchema = z.object(schema) + const getModelId: ProviderModelIdAccessor = (settings) => { + const parsedSettings = settingsSchema.safeParse(settings) + return parsedSettings.success ? modelIdDefinition.getModelId(parsedSettings.data) : undefined + } + + return { + apiProvider, + settingsShape: schema, + modelIdKey: modelIdDefinition.modelIdKey, + schema: z.object({ + ...schema, + [API_PROVIDER_FIELD]: z.literal(apiProvider), + }), + getModelId, + } +} diff --git a/packages/types/src/provider-settings/deepseek.ts b/packages/types/src/provider-settings/deepseek.ts new file mode 100644 index 0000000000..746f87e421 --- /dev/null +++ b/packages/types/src/provider-settings/deepseek.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const deepSeekProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.deepseek, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + deepSeekBaseUrl: z.string().optional(), + deepSeekApiKey: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/fake-ai.ts b/packages/types/src/provider-settings/fake-ai.ts new file mode 100644 index 0000000000..f6be6c72ed --- /dev/null +++ b/packages/types/src/provider-settings/fake-ai.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const fakeAiProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.fakeAi, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + fakeAi: z.unknown().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/fireworks.ts b/packages/types/src/provider-settings/fireworks.ts new file mode 100644 index 0000000000..8d5e1ef3fa --- /dev/null +++ b/packages/types/src/provider-settings/fireworks.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const fireworksProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.fireworks, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + fireworksApiKey: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/friendli.ts b/packages/types/src/provider-settings/friendli.ts new file mode 100644 index 0000000000..c64aa3595b --- /dev/null +++ b/packages/types/src/provider-settings/friendli.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const friendliProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.friendli, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + friendliApiKey: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/gemini-cli.ts b/packages/types/src/provider-settings/gemini-cli.ts new file mode 100644 index 0000000000..29cd66d0ec --- /dev/null +++ b/packages/types/src/provider-settings/gemini-cli.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const geminiCliProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.geminiCli, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + geminiCliOAuthPath: z.string().optional(), + geminiCliProjectId: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/gemini.ts b/packages/types/src/provider-settings/gemini.ts new file mode 100644 index 0000000000..fd287e34ac --- /dev/null +++ b/packages/types/src/provider-settings/gemini.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const geminiProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.gemini, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + geminiApiKey: z.string().optional(), + googleGeminiBaseUrl: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/index.ts b/packages/types/src/provider-settings/index.ts new file mode 100644 index 0000000000..225b54fc29 --- /dev/null +++ b/packages/types/src/provider-settings/index.ts @@ -0,0 +1,86 @@ +import { anthropicProviderDefinition } from "./anthropic.js" +import { openRouterProviderDefinition } from "./openrouter.js" +import { bedrockProviderDefinition } from "./bedrock.js" +import { vertexProviderDefinition } from "./vertex.js" +import { openAiProviderDefinition } from "./openai.js" +import { ollamaProviderDefinition } from "./ollama.js" +import { vsCodeLmProviderDefinition } from "./vscode-lm.js" +import { lmStudioProviderDefinition } from "./lm-studio.js" +import { geminiProviderDefinition } from "./gemini.js" +import { geminiCliProviderDefinition } from "./gemini-cli.js" +import { openAiCodexProviderDefinition } from "./openai-codex.js" +import { openAiNativeProviderDefinition } from "./openai-native.js" +import { mistralProviderDefinition } from "./mistral.js" +import { deepSeekProviderDefinition } from "./deepseek.js" +import { poeProviderDefinition } from "./poe.js" +import { moonshotProviderDefinition } from "./moonshot.js" +import { kimiCodeProviderDefinition } from "./kimi-code.js" +import { minimaxProviderDefinition } from "./minimax.js" +import { mimoProviderDefinition } from "./mimo.js" +import { requestyProviderDefinition } from "./requesty.js" +import { unboundProviderDefinition } from "./unbound.js" +import { fakeAiProviderDefinition } from "./fake-ai.js" +import { xaiProviderDefinition } from "./xai.js" +import { litellmProviderDefinition } from "./litellm.js" +import { sambaNovaProviderDefinition } from "./sambanova.js" +import { zaiProviderDefinition } from "./zai.js" +import { fireworksProviderDefinition } from "./fireworks.js" +import { friendliProviderDefinition } from "./friendli.js" +import { qwenCodeProviderDefinition } from "./qwen-code.js" +import { vercelAiGatewayProviderDefinition } from "./vercel-ai-gateway.js" +import { opencodeGoProviderDefinition } from "./opencode-go.js" +import { kenariProviderDefinition } from "./kenari.js" +import { nanoGptProviderDefinition } from "./nanogpt.js" +import { zooGatewayProviderDefinition } from "./zoo-gateway.js" +import { basetenProviderDefinition } from "./baseten.js" + +import type { ProviderDefinition } from "./common.js" + +export { OPEN_AI_CODEX_SERVICE_TIER_KEY } from "./openai-codex.js" +export { kimiCodeAuthMethodSchema, type KimiCodeAuthMethod } from "./kimi-code.js" +export { zaiApiLineSchema, type ZaiApiLine } from "./zai.js" +export { + nanoGptDefaultRoutingPreference, + nanoGptRoutingPreferences, + nanoGptRoutingPreferenceSchema, + type NanoGptRoutingPreference, +} from "./nanogpt.js" +export type { ProviderDefinition } from "./common.js" + +export const providerDefinitionList = [ + anthropicProviderDefinition, + openRouterProviderDefinition, + bedrockProviderDefinition, + vertexProviderDefinition, + openAiProviderDefinition, + ollamaProviderDefinition, + vsCodeLmProviderDefinition, + lmStudioProviderDefinition, + geminiProviderDefinition, + geminiCliProviderDefinition, + openAiCodexProviderDefinition, + openAiNativeProviderDefinition, + mistralProviderDefinition, + deepSeekProviderDefinition, + poeProviderDefinition, + moonshotProviderDefinition, + kimiCodeProviderDefinition, + minimaxProviderDefinition, + mimoProviderDefinition, + requestyProviderDefinition, + unboundProviderDefinition, + fakeAiProviderDefinition, + xaiProviderDefinition, + litellmProviderDefinition, + sambaNovaProviderDefinition, + zaiProviderDefinition, + fireworksProviderDefinition, + friendliProviderDefinition, + qwenCodeProviderDefinition, + vercelAiGatewayProviderDefinition, + opencodeGoProviderDefinition, + kenariProviderDefinition, + nanoGptProviderDefinition, + zooGatewayProviderDefinition, + basetenProviderDefinition, +] as const satisfies readonly ProviderDefinition[] diff --git a/packages/types/src/provider-settings/kenari.ts b/packages/types/src/provider-settings/kenari.ts new file mode 100644 index 0000000000..da1a082059 --- /dev/null +++ b/packages/types/src/provider-settings/kenari.ts @@ -0,0 +1,17 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +const KENARI_MODEL_ID_FIELD = "kenariModelId" + +export const kenariProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.kenari, + modelIdKey: KENARI_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(KENARI_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + kenariApiKey: z.string().optional(), + [KENARI_MODEL_ID_FIELD]: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/kimi-code.ts b/packages/types/src/provider-settings/kimi-code.ts new file mode 100644 index 0000000000..44e6fd8d7b --- /dev/null +++ b/packages/types/src/provider-settings/kimi-code.ts @@ -0,0 +1,23 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const kimiCodeAuthMethodSchema = z.enum(["oauth", "api-key"]) +export type KimiCodeAuthMethod = z.infer + +export const kimiCodeProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.kimiCode, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + kimiCodeAuthMethod: kimiCodeAuthMethodSchema.optional(), + kimiCodeApiKey: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/litellm.ts b/packages/types/src/provider-settings/litellm.ts new file mode 100644 index 0000000000..018cbdecdd --- /dev/null +++ b/packages/types/src/provider-settings/litellm.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +export const LITELLM_MODEL_ID_FIELD = "litellmModelId" + +export const litellmProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.litellm, + modelIdKey: LITELLM_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(LITELLM_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + litellmBaseUrl: z.string().optional(), + litellmApiKey: z.string().optional(), + [LITELLM_MODEL_ID_FIELD]: z.string().optional(), + litellmUsePromptCache: z.boolean().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/lm-studio.ts b/packages/types/src/provider-settings/lm-studio.ts new file mode 100644 index 0000000000..dea9659d9b --- /dev/null +++ b/packages/types/src/provider-settings/lm-studio.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +export const LM_STUDIO_MODEL_ID_FIELD = "lmStudioModelId" + +export const lmStudioProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.lmstudio, + modelIdKey: LM_STUDIO_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(LM_STUDIO_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + [LM_STUDIO_MODEL_ID_FIELD]: z.string().optional(), + lmStudioBaseUrl: z.string().optional(), + lmStudioDraftModelId: z.string().optional(), + lmStudioSpeculativeDecodingEnabled: z.boolean().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/mimo.ts b/packages/types/src/provider-settings/mimo.ts new file mode 100644 index 0000000000..690ede3839 --- /dev/null +++ b/packages/types/src/provider-settings/mimo.ts @@ -0,0 +1,27 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const mimoProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.mimo, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + mimoBaseUrl: z + .union([ + z.literal("https://api.xiaomimimo.com/v1"), + z.literal("https://token-plan-cn.xiaomimimo.com/v1"), + z.literal("https://token-plan-sgp.xiaomimimo.com/v1"), + z.literal("https://token-plan-ams.xiaomimimo.com/v1"), + ]) + .optional(), + mimoApiKey: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/minimax.ts b/packages/types/src/provider-settings/minimax.ts new file mode 100644 index 0000000000..86e8b1ce98 --- /dev/null +++ b/packages/types/src/provider-settings/minimax.ts @@ -0,0 +1,22 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const minimaxProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.minimax, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + minimaxBaseUrl: z + .union([z.literal("https://api.minimax.io/v1"), z.literal("https://api.minimaxi.com/v1")]) + .optional(), + minimaxApiKey: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/mistral.ts b/packages/types/src/provider-settings/mistral.ts new file mode 100644 index 0000000000..d2353563b6 --- /dev/null +++ b/packages/types/src/provider-settings/mistral.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const mistralProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.mistral, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + mistralApiKey: z.string().optional(), + mistralCodestralUrl: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/moonshot.ts b/packages/types/src/provider-settings/moonshot.ts new file mode 100644 index 0000000000..96292872f3 --- /dev/null +++ b/packages/types/src/provider-settings/moonshot.ts @@ -0,0 +1,25 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const moonshotProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.moonshot, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + moonshotBaseUrl: z + .union([z.literal("https://api.moonshot.ai/v1"), z.literal("https://api.moonshot.cn/v1")]) + .optional(), + moonshotApiKey: z.string().optional(), + }, +}) + +export const kimiCodeAuthMethodSchema = z.enum(["oauth", "api-key"]) +export type KimiCodeAuthMethod = z.infer diff --git a/packages/types/src/provider-settings/nanogpt.ts b/packages/types/src/provider-settings/nanogpt.ts new file mode 100644 index 0000000000..e1db6a237f --- /dev/null +++ b/packages/types/src/provider-settings/nanogpt.ts @@ -0,0 +1,24 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +const NANOGPT_MODEL_ID_FIELD = "nanoGptModelId" + +export const nanoGptRoutingPreferences = ["auto", "fast", "cheap", "latency", "throughput", "tools", "caching"] as const + +export const nanoGptRoutingPreferenceSchema = z.enum(nanoGptRoutingPreferences) +export type NanoGptRoutingPreference = z.infer +export const nanoGptDefaultRoutingPreference: NanoGptRoutingPreference = "auto" + +export const nanoGptProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.nanogpt, + modelIdKey: NANOGPT_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(NANOGPT_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + nanoGptApiKey: z.string().optional(), + [NANOGPT_MODEL_ID_FIELD]: z.string().optional(), + nanoGptRoutingPreference: nanoGptRoutingPreferenceSchema.optional(), + }, +}) diff --git a/packages/types/src/provider-settings/ollama.ts b/packages/types/src/provider-settings/ollama.ts new file mode 100644 index 0000000000..323484ad8d --- /dev/null +++ b/packages/types/src/provider-settings/ollama.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +export const OLLAMA_MODEL_ID_FIELD = "ollamaModelId" + +export const ollamaProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.ollama, + modelIdKey: OLLAMA_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(OLLAMA_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + [OLLAMA_MODEL_ID_FIELD]: z.string().optional(), + ollamaBaseUrl: z.string().optional(), + ollamaApiKey: z.string().optional(), + ollamaNumCtx: z.number().int().min(128).optional(), + }, +}) diff --git a/packages/types/src/provider-settings/openai-codex.ts b/packages/types/src/provider-settings/openai-codex.ts new file mode 100644 index 0000000000..93117224ae --- /dev/null +++ b/packages/types/src/provider-settings/openai-codex.ts @@ -0,0 +1,21 @@ +import { providerIdentifiers } from "../provider-identifiers.js" +import { openAiCodexServiceTierSchema } from "../model.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const OPEN_AI_CODEX_SERVICE_TIER_KEY = "openAiCodexServiceTier" + +export const openAiCodexProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.openaiCodex, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + // Codex "Fast" mode maps to the Responses API priority service tier. + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: openAiCodexServiceTierSchema.optional(), + }, +}) diff --git a/packages/types/src/provider-settings/openai-native.ts b/packages/types/src/provider-settings/openai-native.ts new file mode 100644 index 0000000000..683fd4dfee --- /dev/null +++ b/packages/types/src/provider-settings/openai-native.ts @@ -0,0 +1,24 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { serviceTierSchema } from "../model.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const openAiNativeProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.openaiNative, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + openAiNativeApiKey: z.string().optional(), + openAiNativeBaseUrl: z.string().optional(), + // OpenAI Responses API service tier for openai-native provider only. + // UI should only expose this when the selected model supports flex/priority. + openAiNativeServiceTier: serviceTierSchema.optional(), + }, +}) diff --git a/packages/types/src/provider-settings/openai.ts b/packages/types/src/provider-settings/openai.ts new file mode 100644 index 0000000000..651d90f60d --- /dev/null +++ b/packages/types/src/provider-settings/openai.ts @@ -0,0 +1,26 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { modelInfoSchema } from "../model.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +export const OPEN_AI_MODEL_ID_FIELD = "openAiModelId" + +export const openAiProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.openai, + modelIdKey: OPEN_AI_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(OPEN_AI_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + openAiBaseUrl: z.string().optional(), + openAiApiKey: z.string().optional(), + openAiR1FormatEnabled: z.boolean().optional(), + [OPEN_AI_MODEL_ID_FIELD]: z.string().optional(), + openAiCustomModelInfo: modelInfoSchema.nullish(), + openAiUseAzure: z.boolean().optional(), + azureApiVersion: z.string().optional(), + openAiStreamingEnabled: z.boolean().optional(), + openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration. + openAiHeaders: z.record(z.string(), z.string()).optional(), + }, +}) diff --git a/packages/types/src/provider-settings/opencode-go.ts b/packages/types/src/provider-settings/opencode-go.ts new file mode 100644 index 0000000000..f1e51890a0 --- /dev/null +++ b/packages/types/src/provider-settings/opencode-go.ts @@ -0,0 +1,17 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +export const OPENCODE_GO_MODEL_ID_FIELD = "opencodeGoModelId" + +export const opencodeGoProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.opencodeGo, + modelIdKey: OPENCODE_GO_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(OPENCODE_GO_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + opencodeGoApiKey: z.string().optional(), + [OPENCODE_GO_MODEL_ID_FIELD]: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/openrouter.ts b/packages/types/src/provider-settings/openrouter.ts new file mode 100644 index 0000000000..8ed4480f88 --- /dev/null +++ b/packages/types/src/provider-settings/openrouter.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +export const OPEN_ROUTER_MODEL_ID_FIELD = "openRouterModelId" + +export const openRouterProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.openrouter, + modelIdKey: OPEN_ROUTER_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(OPEN_ROUTER_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + openRouterApiKey: z.string().optional(), + [OPEN_ROUTER_MODEL_ID_FIELD]: z.string().optional(), + openRouterBaseUrl: z.string().optional(), + openRouterSpecificProvider: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/poe.ts b/packages/types/src/provider-settings/poe.ts new file mode 100644 index 0000000000..d5c1055490 --- /dev/null +++ b/packages/types/src/provider-settings/poe.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const poeProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.poe, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + poeApiKey: z.string().optional(), + poeBaseUrl: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/qwen-code.ts b/packages/types/src/provider-settings/qwen-code.ts new file mode 100644 index 0000000000..c1c2f8be81 --- /dev/null +++ b/packages/types/src/provider-settings/qwen-code.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const qwenCodeProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.qwenCode, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + qwenCodeOauthPath: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/requesty.ts b/packages/types/src/provider-settings/requesty.ts new file mode 100644 index 0000000000..ec37d6bb68 --- /dev/null +++ b/packages/types/src/provider-settings/requesty.ts @@ -0,0 +1,18 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +export const REQUESTY_MODEL_ID_FIELD = "requestyModelId" + +export const requestyProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.requesty, + modelIdKey: REQUESTY_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(REQUESTY_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + requestyBaseUrl: z.string().optional(), + requestyApiKey: z.string().optional(), + [REQUESTY_MODEL_ID_FIELD]: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/sambanova.ts b/packages/types/src/provider-settings/sambanova.ts new file mode 100644 index 0000000000..257883747b --- /dev/null +++ b/packages/types/src/provider-settings/sambanova.ts @@ -0,0 +1,23 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const sambaNovaProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.sambanova, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + sambaNovaApiKey: z.string().optional(), + }, +}) + +export const zaiApiLineSchema = z.enum(["international_coding", "china_coding", "international_api", "china_api"]) + +export type ZaiApiLine = z.infer diff --git a/packages/types/src/provider-settings/unbound.ts b/packages/types/src/provider-settings/unbound.ts new file mode 100644 index 0000000000..cadecf5912 --- /dev/null +++ b/packages/types/src/provider-settings/unbound.ts @@ -0,0 +1,17 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +export const UNBOUND_MODEL_ID_FIELD = "unboundModelId" + +export const unboundProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.unbound, + modelIdKey: UNBOUND_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(UNBOUND_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + unboundApiKey: z.string().optional(), + [UNBOUND_MODEL_ID_FIELD]: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/vercel-ai-gateway.ts b/packages/types/src/provider-settings/vercel-ai-gateway.ts new file mode 100644 index 0000000000..c9d838d7bc --- /dev/null +++ b/packages/types/src/provider-settings/vercel-ai-gateway.ts @@ -0,0 +1,17 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +export const VERCEL_AI_GATEWAY_MODEL_ID_FIELD = "vercelAiGatewayModelId" + +export const vercelAiGatewayProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.vercelAiGateway, + modelIdKey: VERCEL_AI_GATEWAY_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(VERCEL_AI_GATEWAY_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + vercelAiGatewayApiKey: z.string().optional(), + [VERCEL_AI_GATEWAY_MODEL_ID_FIELD]: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/vertex.ts b/packages/types/src/provider-settings/vertex.ts new file mode 100644 index 0000000000..2bfa3b114e --- /dev/null +++ b/packages/types/src/provider-settings/vertex.ts @@ -0,0 +1,23 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const vertexProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.vertex, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + vertexKeyFile: z.string().optional(), + vertexJsonCredentials: z.string().optional(), + vertexProjectId: z.string().optional(), + vertexRegion: z.string().optional(), + vertex1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. + }, +}) diff --git a/packages/types/src/provider-settings/vscode-lm.ts b/packages/types/src/provider-settings/vscode-lm.ts new file mode 100644 index 0000000000..8579d69a5b --- /dev/null +++ b/packages/types/src/provider-settings/vscode-lm.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createProviderDefinition } from "./common.js" + +export const vsCodeLmProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.vscodeLm, + getModelId: (settings) => settings.vsCodeLmModelSelector?.id, + schema: { + ...baseProviderSettingsShape, + vsCodeLmModelSelector: z + .object({ + vendor: z.string().optional(), + family: z.string().optional(), + version: z.string().optional(), + id: z.string().optional(), + }) + .optional(), + }, +}) diff --git a/packages/types/src/provider-settings/xai.ts b/packages/types/src/provider-settings/xai.ts new file mode 100644 index 0000000000..d5c2fbb874 --- /dev/null +++ b/packages/types/src/provider-settings/xai.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const xaiProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.xai, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + xaiApiKey: z.string().optional(), + }, +}) diff --git a/packages/types/src/provider-settings/zai.ts b/packages/types/src/provider-settings/zai.ts new file mode 100644 index 0000000000..0f4c4a0333 --- /dev/null +++ b/packages/types/src/provider-settings/zai.ts @@ -0,0 +1,23 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const zaiApiLineSchema = z.enum(["international_coding", "china_coding", "international_api", "china_api"]) +export type ZaiApiLine = z.infer + +export const zaiProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.zai, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + zaiApiKey: z.string().optional(), + zaiApiLine: zaiApiLineSchema.optional(), + }, +}) diff --git a/packages/types/src/provider-settings/zoo-gateway.ts b/packages/types/src/provider-settings/zoo-gateway.ts new file mode 100644 index 0000000000..d2082bff33 --- /dev/null +++ b/packages/types/src/provider-settings/zoo-gateway.ts @@ -0,0 +1,18 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +export const ZOO_GATEWAY_MODEL_ID_FIELD = "zooGatewayModelId" + +export const zooGatewayProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.zooGateway, + modelIdKey: ZOO_GATEWAY_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(ZOO_GATEWAY_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + zooSessionToken: z.string().optional(), + [ZOO_GATEWAY_MODEL_ID_FIELD]: z.string().optional(), + zooGatewayBaseUrl: z.string().optional(), + }, +}) diff --git a/packages/types/src/providers/baseten.ts b/packages/types/src/providers/baseten.ts index 27b8cbff4a..2c1701ab0a 100644 --- a/packages/types/src/providers/baseten.ts +++ b/packages/types/src/providers/baseten.ts @@ -83,6 +83,32 @@ export const basetenModels = { description: "DeepSeek's hybrid reasoning model with efficient long context scaling with GPT-5 level performance", }, + "deepseek-ai/DeepSeek-V4-Pro": { + displayName: "DeepSeek V4 Pro", + maxTokens: 384_000, + contextWindow: 1_000_000, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + inputPrice: 1.74, + outputPrice: 3.48, + cacheReadsPrice: 0.145, + description: + "DeepSeek V4 Pro is a 1.6T-parameter mixture-of-experts model with a 1M context window for advanced reasoning, coding, and agentic workloads.", + }, + "deepseek-ai/DeepSeek-V4-Pro-0813": { + displayName: "DeepSeek V4 Pro 0813", + maxTokens: 384_000, + contextWindow: 1_000_000, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + inputPrice: 1.32, + outputPrice: 3.96, + cacheReadsPrice: 0.132, + description: + "DeepSeek V4 Pro 0813 is a 1.6T-parameter mixture-of-experts model with a 1M context window for advanced reasoning, coding, and agentic workloads.", + }, "openai/gpt-oss-120b": { maxTokens: 16_384, contextWindow: 128_072, diff --git a/packages/types/src/providers/deepseek.ts b/packages/types/src/providers/deepseek.ts index 9387d6a4ae..d9cf7ce755 100644 --- a/packages/types/src/providers/deepseek.ts +++ b/packages/types/src/providers/deepseek.ts @@ -12,32 +12,33 @@ export const deepSeekModels = { "deepseek-v4-flash": { maxTokens: 384_000, contextWindow: 1_000_000, - supportsImages: true, + supportsImages: false, supportsPromptCache: true, - supportsReasoningEffort: ["disable", "low", "high", "max"], // Updated 2026-08-01 + supportsReasoningEffort: ["disable", "low", "high", "max"], // Updated 2026-08-13 preserveReasoning: true, reasoningEffort: "high", inputPrice: 0, // the inputs are priced as cache read/write, so `inputPrice` should be 0 - // the peak/off-peak pricing policy has not been implemented yet - Updated 2026-08-01 - outputPrice: 0.28, // $0.28 per million tokens - Updated 2026-08-01 - cacheWritesPrice: 0.14, // $0.14 per million tokens (cache miss) - Updated 2026-08-01 - cacheReadsPrice: 0.0028, // $0.0028 per million tokens (cache hit) - Updated 2026-08-01 + // Static estimates use peak rates; off-peak rates are 50% lower. Effective 2026-08-16. + outputPrice: 1.32, + cacheWritesPrice: 0.44, + cacheReadsPrice: 0.014, description: `DeepSeek-V4-Flash is DeepSeek's fast, cost-efficient V4 model. It supports thinking and non-thinking modes, JSON output, tool calls, chat prefix completion (beta), and FIM completion (beta) in non-thinking mode.`, }, "deepseek-v4-pro": { + displayName: "DeepSeek V4 Pro 0813", maxTokens: 384_000, contextWindow: 1_000_000, - supportsImages: true, + supportsImages: false, supportsPromptCache: true, - supportsReasoningEffort: ["disable", "high", "max"], // Updated 2026-08-01 + supportsReasoningEffort: ["disable", "low", "high", "max"], // Updated 2026-08-13 preserveReasoning: true, reasoningEffort: "high", inputPrice: 0, // the inputs are priced as cache read/write, so `inputPrice` should be 0 - // the peak/off-peak pricing policy has not been implemented yet - Updated 2026-08-01 - outputPrice: 0.87, // $0.87 per million tokens - Updated 2026-08-01 - cacheWritesPrice: 0.435, // $0.435 per million tokens (cache miss) - Updated 2026-08-01 - cacheReadsPrice: 0.003625, // $0.003625 per million tokens (cache hit) - Updated 2026-08-01 - description: `DeepSeek-V4-Pro is DeepSeek's strongest V4 model for reasoning, coding, long-context, and agentic workloads. It supports thinking and non-thinking modes, JSON output, tool calls, chat prefix completion (beta), and FIM completion (beta) in non-thinking mode.`, + // Static estimates use peak rates; off-peak rates are 50% lower. Effective 2026-08-16. + outputPrice: 3.96, + cacheWritesPrice: 1.32, + cacheReadsPrice: 0.044, + description: `DeepSeek-V4-Pro-0813 is DeepSeek's strongest V4 model for reasoning, coding, long-context, and agentic workloads. It supports thinking and non-thinking modes, JSON output, tool calls, chat prefix completion (beta), and FIM completion (beta) in non-thinking mode.`, }, } as const satisfies Record diff --git a/packages/types/src/providers/fireworks.ts b/packages/types/src/providers/fireworks.ts index 3b18ad50b8..993bf1ad8e 100644 --- a/packages/types/src/providers/fireworks.ts +++ b/packages/types/src/providers/fireworks.ts @@ -16,6 +16,7 @@ export type FireworksModelId = | "accounts/fireworks/models/deepseek-v3p1" | "accounts/fireworks/models/deepseek-v3p2" | "accounts/fireworks/models/deepseek-v4-pro" + | "accounts/fireworks/models/deepseek-v4-pro-0813" | "accounts/fireworks/models/glm-4p5" | "accounts/fireworks/models/glm-4p5-air" | "accounts/fireworks/models/glm-4p6" @@ -262,10 +263,23 @@ export const fireworksModels = { supportsPromptCache: true, inputPrice: 1.74, outputPrice: 3.48, - cacheReadsPrice: 0.14, + cacheReadsPrice: 0.145, description: "DeepSeek V4 Pro is the latest iteration of the DeepSeek model family, with improved reasoning, code generation, and instruction following over the V3 series.", }, + "accounts/fireworks/models/deepseek-v4-pro-0813": { + displayName: "DeepSeek V4 Pro 0813", + maxTokens: 384_000, + contextWindow: 1_000_000, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + inputPrice: 1.32, + outputPrice: 3.96, + cacheReadsPrice: 0.044, + description: + "DeepSeek V4 Pro 0813 is DeepSeek's production checkpoint for advanced reasoning, coding, and long-context agentic workloads.", + }, "accounts/fireworks/models/kimi-k2p7-code": { maxTokens: 16384, contextWindow: 262144, diff --git a/packages/types/src/providers/gemini.ts b/packages/types/src/providers/gemini.ts index e180be0867..b65f842a59 100644 --- a/packages/types/src/providers/gemini.ts +++ b/packages/types/src/providers/gemini.ts @@ -6,6 +6,19 @@ export type GeminiModelId = keyof typeof geminiModels export const geminiDefaultModelId: GeminiModelId = "gemini-3.1-pro-preview" export const geminiModels = { + "gemini-3.7-flash": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 0.75, + outputPrice: 3.75, + cacheReadsPrice: 0.075, + cacheWritesPrice: 0.5, + supportsReasoningBudget: false, + }, "gemini-3.6-flash": { maxTokens: 65_536, contextWindow: 1_048_576, @@ -132,6 +145,37 @@ export const geminiModels = { outputPrice: 3.0, cacheReadsPrice: 0.05, }, + // 3.x Flash Lite models + "gemini-3.5-flash-lite": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + // Matches the documented API default thinking level (On (minimal)). + reasoningEffort: "minimal", + inputPrice: 0.3, + outputPrice: 2.5, + cacheReadsPrice: 0.03, + cacheWritesPrice: 1.0, + supportsReasoningBudget: false, + }, + "gemini-3.1-flash-lite": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + // Lowest level of the supported set: keeps the cheap/free tier's + // default latency and cost in line with the Flash Lite tier. + reasoningEffort: "minimal", + inputPrice: 0.25, + outputPrice: 1.5, + cacheReadsPrice: 0.025, + cacheWritesPrice: 1.0, + supportsReasoningBudget: false, + }, + // 2.5 Pro models "gemini-2.5-pro": { maxTokens: 64_000, diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 5f7d1afda4..b26476eaed 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -25,6 +25,7 @@ export * from "./xai.js" export * from "./vercel-ai-gateway.js" export * from "./opencode-go.js" export * from "./kenari.js" +export * from "./nanogpt.js" export * from "./kimi-code.js" export * from "./zai.js" export * from "./minimax.js" @@ -54,6 +55,7 @@ import { xaiDefaultModelId } from "./xai.js" import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js" import { opencodeGoDefaultModelId } from "./opencode-go.js" import { kenariDefaultModelId } from "./kenari.js" +import { nanoGptDefaultModelId } from "./nanogpt.js" import { kimiCodeDefaultModelId } from "./kimi-code.js" import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js" import { minimaxDefaultModelId } from "./minimax.js" @@ -133,6 +135,8 @@ export function getProviderDefaultModelId( return opencodeGoDefaultModelId case providerIdentifiers.kenari: return kenariDefaultModelId + case providerIdentifiers.nanogpt: + return nanoGptDefaultModelId case providerIdentifiers.kimiCode: return kimiCodeDefaultModelId case providerIdentifiers.zooGateway: diff --git a/packages/types/src/providers/lite-llm.ts b/packages/types/src/providers/lite-llm.ts index 2b72de9d51..2e19b5b8b7 100644 --- a/packages/types/src/providers/lite-llm.ts +++ b/packages/types/src/providers/lite-llm.ts @@ -24,14 +24,12 @@ export const litellmDefaultModelInfo: ModelInfo = { * * Rather than matching model-family substrings with a regex (which can * over-match unrelated aliases, e.g. a family fragment appearing inside a - * longer unrelated model id), this is an explicit list of the exact model - * ids that set `preserveReasoning: true` in their native provider config - * (see deepseek.ts, mimo.ts, moonshot.ts, bedrock.ts, fireworks.ts, zai.ts, - * minimax.ts, opencode-go.ts). The same behavior is inferred for a - * LiteLLM-routed alias of the same underlying model. Keep this list in sync - * with those registries. This is still best-effort: unrecognized aliases or - * renamed deployments will not match, and callers should treat it as a - * heuristic, not a source of truth. + * longer unrelated model id), this is an explicit list of models whose + * OpenAI-compatible routes use interleaved `reasoning_content`. Native + * provider metadata informs this list where applicable, but gateway routes + * can have different preservation semantics. This is still best-effort: + * unrecognized aliases or renamed deployments will not match, and callers + * should treat it as a heuristic, not a source of truth. */ export const LITELLM_PRESERVE_REASONING_MODEL_IDS = [ // deepseek.ts @@ -56,6 +54,7 @@ export const LITELLM_PRESERVE_REASONING_MODEL_IDS = [ "glm-5", "glm-5.1", "glm-5.2", + "glm-5.3", "glm-5-turbo", // bedrock.ts, minimax.ts, opencode-go.ts @@ -74,6 +73,7 @@ export const LITELLM_PRESERVE_REASONING_MODEL_IDS = [ "qwen3.6-plus", "qwen3.7-plus", "qwen3.7-max", + "qwen3.8-max", ] as const const LITELLM_PRESERVE_REASONING_MODEL_ID_SET = new Set(LITELLM_PRESERVE_REASONING_MODEL_IDS) diff --git a/packages/types/src/providers/lm-studio.ts b/packages/types/src/providers/lm-studio.ts index d0df134470..5360178840 100644 --- a/packages/types/src/providers/lm-studio.ts +++ b/packages/types/src/providers/lm-studio.ts @@ -1,5 +1,15 @@ +import { z } from "zod" + import type { ModelInfo } from "../model.js" +export const lmStudioModelsMessageTypes = ["requestLmStudioModels", "lmStudioModels"] as const + +export const lmStudioModelsMessageTypeSchema = z.enum(lmStudioModelsMessageTypes) + +export const LmStudioModelsMessageType = lmStudioModelsMessageTypeSchema.enum + +export type LmStudioModelsMessageType = z.infer + export const LMSTUDIO_DEFAULT_TEMPERATURE = 0 // LM Studio diff --git a/packages/types/src/providers/nanogpt.ts b/packages/types/src/providers/nanogpt.ts new file mode 100644 index 0000000000..1b0459ab24 --- /dev/null +++ b/packages/types/src/providers/nanogpt.ts @@ -0,0 +1,57 @@ +import type { ModelInfo } from "../model.js" +import type { NanoGptRoutingPreference } from "../provider-settings/nanogpt.js" + +export const NANOGPT_BASE_URL = "https://nano-gpt.com/api/v1" + +export const nanoGptDefaultModelId = "openai/gpt-5.6-sol" + +export const nanoGptDefaultModelInfo: ModelInfo = { + maxTokens: 128_000, + contextWindow: 1_050_000, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 5, + outputPrice: 30, + description: "NanoGPT model. Available models and metadata are resolved dynamically from the detailed catalog.", +} + +const ROUTING_SUFFIXES = new Set([ + "speed", + "fast", + "throughput", + "latency", + "price", + "cheap", + "floor", + "tools", + "caching", + "cache", + "cached", +]) + +const ROUTING_SUFFIX_BY_PREFERENCE: Record, string> = { + fast: "fast", + cheap: "cheap", + latency: "latency", + throughput: "throughput", + tools: "tools", +} + +/** Applies one request-only NanoGPT routing suffix while preserving identity suffixes such as `:thinking`. */ +export function applyNanoGptRoutingPreference(modelId: string, preference: NanoGptRoutingPreference = "auto"): string { + let canonicalId = modelId + let separatorIndex = canonicalId.lastIndexOf(":") + let finalSuffix = separatorIndex >= 0 ? canonicalId.slice(separatorIndex + 1).toLowerCase() : "" + + // Normalize every trailing routing alias. This protects request identity when a + // previously-routed ID is routed again and prevents multiple active suffixes. + while (separatorIndex >= 0 && ROUTING_SUFFIXES.has(finalSuffix)) { + canonicalId = canonicalId.slice(0, separatorIndex) + separatorIndex = canonicalId.lastIndexOf(":") + finalSuffix = separatorIndex >= 0 ? canonicalId.slice(separatorIndex + 1).toLowerCase() : "" + } + + return preference === "auto" || preference === "caching" + ? canonicalId + : `${canonicalId}:${ROUTING_SUFFIX_BY_PREFERENCE[preference]}` +} diff --git a/packages/types/src/providers/ollama.ts b/packages/types/src/providers/ollama.ts index 160083511f..e97a480b88 100644 --- a/packages/types/src/providers/ollama.ts +++ b/packages/types/src/providers/ollama.ts @@ -1,7 +1,17 @@ +import { z } from "zod" + import type { ModelInfo } from "../model.js" // Ollama // https://ollama.com/models +export const ollamaModelsMessageTypes = ["requestOllamaModels", "ollamaModels"] as const + +export const ollamaModelsMessageTypeSchema = z.enum(ollamaModelsMessageTypes) + +export const OllamaModelsMessageType = ollamaModelsMessageTypeSchema.enum + +export type OllamaModelsMessageType = z.infer + export const ollamaDefaultModelId = "devstral:24b" export const ollamaDefaultModelInfo: ModelInfo = { maxTokens: 4096, diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts index b090509bc2..acf5649624 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -1,6 +1,16 @@ +import { z } from "zod" + import type { ModelInfo } from "../model.js" // https://openai.com/api/pricing/ +export const openAiModelsMessageTypes = ["requestOpenAiModels", "openAiModels"] as const + +export const openAiModelsMessageTypeSchema = z.enum(openAiModelsMessageTypes) + +export const OpenAiModelsMessageType = openAiModelsMessageTypeSchema.enum + +export type OpenAiModelsMessageType = z.infer + export type OpenAiNativeModelId = keyof typeof openAiNativeModels export const OPENAI_API_PROTOCOL = "openai" diff --git a/packages/types/src/providers/opencode-go.ts b/packages/types/src/providers/opencode-go.ts index bd60c4d349..a7ae0de259 100644 --- a/packages/types/src/providers/opencode-go.ts +++ b/packages/types/src/providers/opencode-go.ts @@ -86,6 +86,21 @@ export const opencodeGoModels: Record = { description: "GLM-5.1 is Zhipu's most capable model with a 200k context window, 128k max output, and built-in thinking capabilities. Available via the Opencode Go plan.", }, + "glm-5.3": { + maxTokens: 131_072, + contextWindow: 1_000_000, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "max", + preserveReasoning: true, + inputPrice: 1.4, + outputPrice: 4.4, + cacheReadsPrice: 0.26, + description: + "GLM-5.3 is Zhipu's flagship coding and agent model with a 1M context window, 128k max output, and always-on reasoning with configurable effort (Low/High/Max). Available via the Opencode Go plan.", + }, "glm-5.2": { maxTokens: 131_072, contextWindow: 1_000_000, @@ -292,9 +307,23 @@ export const opencodeGoModels: Record = { description: "Qwen3.7 Max - Alibaba's flagship text-only reasoning agent model with a 1M context window, designed for long-horizon agent workflows. Available via the Opencode Go plan.", }, + "qwen3.8-max": { + maxTokens: 131_072, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + supportsMaxTokens: true, + inputPrice: 2.0, + outputPrice: 6.0, + cacheReadsPrice: 0.25, + cacheWritesPrice: 2.5, + description: + "Qwen3.8 Max - Alibaba's flagship multimodal reasoning model with a 1M context window, 128k max output, and long-horizon coding and agentic capabilities. Available via the Opencode Go plan.", + }, // --- DeepSeek --- "deepseek-v4-pro": { + displayName: "DeepSeek V4 Pro 0813", maxTokens: 384_000, contextWindow: 1_000_000, supportsImages: false, @@ -308,11 +337,11 @@ export const opencodeGoModels: Record = { supportsReasoningEffort: ["disable", "low", "medium", "high", "xhigh"], preserveReasoning: true, reasoningEffort: "high", - inputPrice: 1.74, - outputPrice: 3.48, - cacheReadsPrice: 0.0145, + inputPrice: 0.435, + outputPrice: 0.87, + cacheReadsPrice: 0.003625, description: - "DeepSeek-V4-Pro is DeepSeek's strongest V4 model for reasoning, coding, long-context, and agentic workloads. Available via the Opencode Go plan.", + "DeepSeek-V4-Pro-0813 is DeepSeek's strongest V4 model for reasoning, coding, long-context, and agentic workloads. Available via the Opencode Go plan.", }, "deepseek-v4-flash": { maxTokens: 384_000, @@ -349,6 +378,7 @@ export const opencodeGoModels: Record = { */ export const OPENCODE_GO_ANTHROPIC_FORMAT_MODELS = new Set([ // --- Alibaba Qwen --- + "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index 46ff835682..1509f40469 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -6,6 +6,19 @@ export type VertexModelId = keyof typeof vertexModels export const vertexDefaultModelId: VertexModelId = "claude-sonnet-4-5@20250929" export const vertexModels = { + "gemini-3.7-flash": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 0.75, + outputPrice: 3.75, + cacheReadsPrice: 0.075, + cacheWritesPrice: 0.5, + supportsReasoningBudget: false, + }, "gemini-3.6-flash": { maxTokens: 65_536, contextWindow: 1_048_576, diff --git a/packages/types/src/providers/vscode-llm.ts b/packages/types/src/providers/vscode-llm.ts index 5286b0ed28..7069f49f54 100644 --- a/packages/types/src/providers/vscode-llm.ts +++ b/packages/types/src/providers/vscode-llm.ts @@ -1,5 +1,15 @@ +import { z } from "zod" + import type { ModelInfo } from "../model.js" +export const vsCodeLmModelsMessageTypes = ["requestVsCodeLmModels", "vsCodeLmModels"] as const + +export const vsCodeLmModelsMessageTypeSchema = z.enum(vsCodeLmModelsMessageTypes) + +export const VsCodeLmModelsMessageType = vsCodeLmModelsMessageTypeSchema.enum + +export type VsCodeLmModelsMessageType = z.infer + export type VscodeLlmModelId = keyof typeof vscodeLlmModels export const vscodeLlmDefaultModelId: VscodeLlmModelId = "claude-sonnet-4.5" diff --git a/packages/types/src/providers/zai.ts b/packages/types/src/providers/zai.ts index c2bd079264..79af21d8dc 100644 --- a/packages/types/src/providers/zai.ts +++ b/packages/types/src/providers/zai.ts @@ -5,11 +5,27 @@ import { ZaiApiLine } from "../provider-settings.js" // https://docs.z.ai/guides/llm/glm-4-32b-0414-128k // https://docs.z.ai/guides/llm/glm-4.5 // https://docs.z.ai/guides/llm/glm-4.6 +// https://docs.z.ai/guides/llm/glm-5.3 // https://docs.z.ai/guides/llm/glm-5.1 // https://docs.z.ai/guides/llm/glm-5-turbo // https://docs.z.ai/guides/overview/pricing // https://bigmodel.cn/pricing +const glm53ModelInfo = { + maxTokens: 131_072, + contextWindow: 1_000_000, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + supportsReasoningEffort: ["low", "high", "max"], + requiredReasoningEffort: true, + reasoningEffort: "max", + preserveReasoning: true, + defaultTemperature: 1, + description: + "GLM-5.3 is Zhipu's flagship coding and agent model with a 1M context window, 128k max output, and always-on reasoning with configurable effort (Low/High/Max).", +} as const satisfies ModelInfo + export type InternationalZAiModelId = keyof typeof internationalZAiModels export const internationalZAiDefaultModelId: InternationalZAiModelId = "glm-4.7" export const internationalZAiModels = { @@ -170,6 +186,13 @@ export const internationalZAiModels = { description: "GLM-5.2 is Zhipu's flagship model with a 1M context window, 128k max output, and dual thinking-effort modes (High/Max). It delivers top-tier long-context reasoning, coding, and agentic performance for extended engineering sessions.", }, + "glm-5.3": { + ...glm53ModelInfo, + inputPrice: 1.4, + outputPrice: 4.4, + cacheWritesPrice: 0, + cacheReadsPrice: 0.26, + }, "glm-5-turbo": { maxTokens: 131_072, contextWindow: 202_752, @@ -473,6 +496,17 @@ export const mainlandZAiModels = { }, } as const satisfies Record +export const mainlandZAiCodingPlanOnlyModels = { + "glm-5.3": { + ...glm53ModelInfo, + // GLM-5.3 API pricing is not published yet; use GLM-5.2 pricing provisionally. + inputPrice: 0.68, + outputPrice: 2.28, + cacheWritesPrice: 0, + cacheReadsPrice: 0.13, + }, +} as const satisfies Record + export const ZAI_DEFAULT_TEMPERATURE = 0.6 export const zaiApiLineConfigs = { @@ -497,3 +531,11 @@ export const zaiApiLineConfigs = { isChina: true, }, } satisfies Record + +export function getZAiModels(apiLine: ZaiApiLine = "international_coding"): Record { + const isChina = zaiApiLineConfigs[apiLine].isChina + const regionalModels = isChina ? mainlandZAiModels : internationalZAiModels + return isChina && apiLine.endsWith("_coding") + ? { ...regionalModels, ...mainlandZAiCodingPlanOnlyModels } + : regionalModels +} diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 402cd571c8..d69476836e 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -13,6 +13,18 @@ export const telemetrySettingsSchema = z.enum(telemetrySettings) export type TelemetrySetting = z.infer +/** + * Whether telemetry should be captured for this install. + * + * Telemetry is on by default (disclosed opt-out): "unset" (no choice made yet) and + * "enabled" both mean telemetry may be captured. Only an explicit "disabled" opts out. + * The consent banner's dismiss/close action never writes a setting, so it stays neutral -- + * it just leaves the default in effect rather than recording an affirmative choice either way. + */ +export function isTelemetryOptedIn(telemetrySetting: TelemetrySetting | undefined): boolean { + return telemetrySetting !== "disabled" +} + /** * TelemetryEventName */ diff --git a/packages/types/src/type-fu.ts b/packages/types/src/type-fu.ts index 0014e9b187..69f558f21a 100644 --- a/packages/types/src/type-fu.ts +++ b/packages/types/src/type-fu.ts @@ -6,6 +6,10 @@ export type Keys = keyof T export type Values = T[keyof T] +export type UnionToIntersection = (U extends unknown ? (value: U) => void : never) extends (value: infer I) => void + ? I + : never + export type Equals = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false export type AssertEqual = T diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 63d5be87a8..4a9aabd102 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -12,7 +12,11 @@ import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, import type { SerializedCustomToolDefinition } from "./custom-tool.js" import type { GitCommit } from "./git.js" import type { McpServer } from "./mcp.js" -import type { ModelRecord, RouterModels } from "./model.js" +import { RouterModelsMessageType, type ModelRecord, type RouterModels } from "./model.js" +import { LmStudioModelsMessageType } from "./providers/lm-studio.js" +import { OllamaModelsMessageType } from "./providers/ollama.js" +import { OpenAiModelsMessageType } from "./providers/openai.js" +import { VsCodeLmModelsMessageType } from "./providers/vscode-llm.js" import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js" import type { SkillMetadata } from "./skills.js" import type { RuleMetadata } from "./rules.js" @@ -38,12 +42,12 @@ export interface ExtensionMessage { | "enhancedPrompt" | "commitSearchResults" | "listApiConfig" - | "routerModels" + | typeof RouterModelsMessageType.routerModels | "zooGatewayCredentialsReady" - | "openAiModels" - | "ollamaModels" - | "lmStudioModels" - | "vsCodeLmModels" + | typeof OpenAiModelsMessageType.openAiModels + | typeof OllamaModelsMessageType.ollamaModels + | typeof LmStudioModelsMessageType.lmStudioModels + | typeof VsCodeLmModelsMessageType.vsCodeLmModels | "vsCodeLmApiAvailable" | "updatePrompt" | "systemPrompt" @@ -69,7 +73,7 @@ export interface ExtensionMessage { | "authenticatedUser" | "condenseTaskContextStarted" | "condenseTaskContextResponse" - | "singleRouterModelFetchResponse" + | typeof RouterModelsMessageType.singleRouterModelFetchResponse | "indexingStatusUpdate" | "indexCleared" | "codebaseIndexConfig" @@ -103,6 +107,7 @@ export interface ExtensionMessage { | "rules" | "fileContent" | "rooHistoryImportProgress" + | "themeFixtureProbeRequest" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -150,6 +155,7 @@ export interface ExtensionMessage { // eslint-disable-next-line @typescript-eslint/no-explicit-any values?: Record requestId?: string + themeFixture?: WebviewThemeFixture promptText?: string results?: | { path: string; type: "file" | "folder"; label?: string }[] @@ -360,6 +366,11 @@ export type ExtensionState = Pick< telemetrySetting: TelemetrySetting telemetryKey?: string machineId?: string + // Live vscode.env.isTelemetryEnabled, so the webview's own PostHog client can respect + // the VS Code global telemetry toggle the same way the extension-side gate does -- + // without this, an explicit user Accept can still send events while VS Code's global + // telemetry is disabled. + vscodeTelemetryEnabled?: boolean renderContext: "sidebar" | "editor" settingsImportedAt?: number @@ -474,13 +485,13 @@ export interface WebviewMessage { | "importSettings" | "exportSettings" | "resetState" - | "flushRouterModels" - | "requestRouterModels" - | "requestOpenAiModels" - | "requestOllamaModels" - | "requestLmStudioModels" + | typeof RouterModelsMessageType.flushRouterModels + | typeof RouterModelsMessageType.requestRouterModels + | typeof OpenAiModelsMessageType.requestOpenAiModels + | typeof OllamaModelsMessageType.requestOllamaModels + | typeof LmStudioModelsMessageType.requestLmStudioModels | "requestRooModels" - | "requestVsCodeLmModels" + | typeof VsCodeLmModelsMessageType.requestVsCodeLmModels | "openImage" | "saveImage" | "openFile" @@ -632,6 +643,7 @@ export interface WebviewMessage { | "deleteRule" | "openRuleFile" | "openRulesDirectory" + | "themeFixtureProbeResponse" text?: string taskId?: string editedMessageContent?: string @@ -678,6 +690,7 @@ export interface WebviewMessage { /** Target mode slugs for updateSkillModes */ newSkillModeSlugs?: string[] // For updateSkillModes (new mode restrictions) requestId?: string + themeFixture?: WebviewThemeFixture ids?: string[] terminalOperation?: "continue" | "abort" messageTs?: number @@ -744,6 +757,12 @@ export interface WebviewMessage { worktreeIncludeContent?: string } +export interface WebviewThemeFixture { + themeId: string + bodyClass: string + variables: Record +} + export interface RequestOpenAiCodexRateLimitsMessage { type: "requestOpenAiCodexRateLimits" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 393c6ac143..bbe101f0c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -668,6 +668,9 @@ importers: '@types/vscode': specifier: 1.100.0 version: 1.100.0 + '@typescript-eslint/parser': + specifier: 8.32.1 + version: 8.32.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 4.1.9 version: 4.1.9(vitest@4.1.9) diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md new file mode 100644 index 0000000000..72bfdbd415 --- /dev/null +++ b/src/CHANGELOG.md @@ -0,0 +1,3852 @@ +# Zoo Code Changelog + +## [3.78.0] + +### Minor Changes + +- Add NanoGPT as a configurable provider with dynamic model discovery, streaming and prompt completions, and routing preferences for speed, price, latency, throughput, tool support, and caching (PR #1239 by @taltas) +- Add the new Gemini 3.7 Flash model to Google Gemini and Vertex AI with a 1M context window, multimodal input, prompt caching, and configurable reasoning (PR #1241 by @app/zoomote) +- Add the new GLM 5.3 model to Z AI coding plans and OpenCode Go with a 1M context window, prompt caching, and extended reasoning controls (PR #1244 by @app/zoomote) +- Add the new Qwen3.8 Max model to OpenCode Go with multimodal input, caching, streamed reasoning, and Anthropic Messages routing (PR #1245 by @app/zoomote) +- Fix Azure OpenAI resource endpoints and improve Azure-specific setup guidance in OpenAI Compatible settings (#1191 by @edelauna, PR #1192 by @app/zoomote) +- Preserve task-history titles when rapidly navigating away before a task's messages finish loading (#1180 by @edelauna, PR #1181 by @edelauna) +- Correct Kimi Code output-token defaults and honor model limits returned by the server (#1215 by @myk1yt, PR #1217 by @myk1yt) +- Update DeepSeek V4 Pro reasoning efforts and normalize medium, high, and extended reasoning mappings (#1235 by @WHMHammer, PR #1236 by @WHMHammer) +- Correct DeepSeek V4 pricing and expand provider coverage for the V4 Pro 0813 checkpoint (PR #1237 by @app/zoomote) +- Rename the default settings import/export file to `zoo-code-settings.json` throughout the extension (#1176 by @Rafael-Silva-Oliveira, PR #1177 by @Rafael-Silva-Oliveira) +- Add Destructive Command Guard support for Intel-based macOS systems (PR #1213 by @app/zoomote) +- Update `undici` to 6.28.0 to address security vulnerabilities (PR #1161 by @app/renovate) +- Update Mermaid to 11.16.1 to address a prototype-pollution vulnerability (PR #1193 by @app/renovate) +- Record tool usage centrally to prevent duplicate telemetry and sanitize raw tool names (PR #1073 by @edelauna) +- Canonicalize shared provider settings identifiers and add registry-alignment coverage (PR #1109 by @WebMad) +- Canonicalize provider identifiers across CLI configuration, environment mappings, and model selection (PR #1110 by @WebMad) +- Complete the webview migration to canonical provider identifiers across provider settings and routing (PR #1141 by @WebMad) +- Use canonical provider identifiers throughout API options and add focused interaction coverage (PR #1146 by @WebMad) +- Canonicalize provider model configuration identifiers and provider-specific settings behavior (PR #1147 by @WebMad) +- Migrate model-selection UI hooks to canonical provider identifiers (PR #1148 by @WebMad) +- Reuse the retired Roo provider identifier registry while preserving migration compatibility (PR #1166 by @WebMad) +- Introduce typed shared test utilities for API options, filesystem mocks, reset operations, VS Code doubles, and webview rendering (PR #1171 by @app/zoomote) +- Reuse shared API option factories across Requesty, OpenRouter, and Vercel AI Gateway provider tests (PR #1178 by @app/zoomote) +- Reuse the shared Responses client mock in X.AI provider tests (PR #1182 by @app/zoomote) +- Reuse shared VS Code context, URI, and reset helpers in custom-mode configuration tests (PR #1190 by @app/zoomote) +- Reuse shared reset helpers across code-index embedder tests (PR #1194 by @app/zoomote) +- Reuse shared config test helpers and remove obsolete lint suppressions (PR #1195 by @app/zoomote) +- Reuse shared webview render helpers across focused chat tests (PR #1196 by @app/zoomote) +- Reuse shared reset helpers across terminal integration tests (PR #1197 by @app/zoomote) +- Reuse shared VS Code and reset helpers in settings import/export tests (PR #1198 by @app/zoomote) +- Reuse shared reset helpers across additional code-index tests (PR #1199 by @app/zoomote) +- Complete another batch of shared reset-helper adoption in code-index tests (PR #1200 by @app/zoomote) +- Finish reset-helper adoption in Semble and terminal test suites (PR #1201 by @app/zoomote) +- Complete shared reset-helper adoption across provider tests (PR #1202 by @app/zoomote) +- Reuse shared webview render helpers across chat and settings tests (PR #1203 by @app/zoomote) +- Complete shared webview render-helper adoption in the remaining settings tests (PR #1204 by @app/zoomote) +- Merge the v3.76.0 release preparation branch into `main` (PR #1173 by @navedmerchant) + +## [3.76.0] + +### Minor Changes + +- Add Destructive Command Guard as an opt-in safety layer that blocks dangerous terminal commands while allowing longer tasks to continue without repeated approvals (#1057 by @navedmerchant, PR #1061 by @navedmerchant) +- Streamline Destructive Command Guard approval handling so safe commands can continue with fewer interruptions (#1058 by @navedmerchant, PR #1062 by @navedmerchant) +- Harden Destructive Command Guard binary installation and platform handling (#1056 by @navedmerchant, PR #1060 by @navedmerchant) +- Strengthen shared managed-binary downloads, archive extraction, verification, and atomic installation (#1055 by @navedmerchant, PR #1059 by @navedmerchant) +- Fix Zoo starting the next step before a terminal command finishes (PR #1145 by @app/zoomote) +- Add an OpenAI Codex response-speed selector to provider settings (PR #1076 by @WebMad) +- Update DeepSeek model configurations and defaults (#1082 by @WHMHammer, PR #1083 by @WHMHammer) +- Isolate each task's provider configuration from changes to the currently focused provider state (PR #1085 by @edelauna) +- Isolate provider-profile mutations from running tasks to prevent mid-task configuration changes (PR #1087 by @edelauna) +- Group nearby, related tool approval requests while keeping unrelated requests separate (#1004 by @easonLiangWorldedtech, PR #1005 by @easonLiangWorldedtech) +- Add telemetry circuit breaking and drain pending events during shutdown for more reliable, bounded delivery (PR #1070 by @edelauna) +- Aggregate task-completion telemetry using incremental delta installments (PR #1071 by @edelauna) +- Deduplicate concurrent model-cache fetches and throttle telemetry for empty provider responses (PR #1072 by @edelauna) +- Fix Semble archive extraction on Windows by safely encoding the extraction command (#1167 by @navedmerchant, PR #1168 by @navedmerchant) +- Introduce shared provider-stream test utilities to reduce duplicated stream setup (PR #1086 by @app/zoomote) +- Reuse the shared webview query-client helper in tests (PR #1088 by @app/zoomote) +- Migrate Requesty provider tests to shared stream helpers (PR #1089 by @app/zoomote) +- Migrate Unbound provider tests to shared stream helpers (PR #1090 by @app/zoomote) +- Migrate OpenAI-family provider tests to shared stream helpers (PR #1091 by @app/zoomote) +- Migrate base OpenAI-compatible provider tests to shared stream helpers (PR #1092 by @app/zoomote) +- Migrate OpenAI provider tests to shared stream helpers (PR #1093 by @app/zoomote) +- Migrate Anthropic Vertex provider tests to shared stream helpers (PR #1094 by @app/zoomote) +- Migrate LiteLLM and Z.ai provider tests to shared stream helpers (PR #1095 by @app/zoomote) +- Migrate Friendli, OpenCode Go, and MiMo provider tests to shared stream helpers (PR #1102 by @app/zoomote) +- Migrate Qwen, LM Studio, and Fireworks provider tests to shared stream helpers (PR #1103 by @app/zoomote) +- Migrate Kenari, MiniMax, Vercel, and Mistral provider tests to shared stream helpers (PR #1105 by @app/zoomote) +- Finish the provider-stream test-helper rollout across the remaining provider suites (PR #1169 by @app/zoomote) +- Merge the v3.74.0 release preparation branch into `main` (PR #1081 by @taltas) + +## [3.74.0] + +### Minor Changes + +- Add Fast priority mode for OpenAI Codex, persisting the selection and sending it with requests (PR #1063 by @WebMad) +- Add higher reasoning-effort options for OpenAI-compatible models (#882 by @MINLEGO, PR #1051 by @ivanarifin) +- Fetch router-provider model metadata before making context-management decisions (PR #1053 by @JamesRobert20) +- Make Ollama model refreshes reliable, surface refresh errors, and use the base URL currently being edited (#877 by @navedmerchant, PR #878 by @navedmerchant) +- Fix Amazon Bedrock DNS resolution when using a corporate proxy (#905 by @LouisClt, PR #906 by @LouisClt) +- Add reasoning-parameter support to the Friendli provider (PR #886 by @Lee-Si-Yoon) +- Keep Save-managed settings in the local editing buffer until the user explicitly saves (#862 by @JunyongParkDev, PR #872 by @JunyongParkDev) +- Stop prompting about command output after short foreground commands have already completed (#1042 by @edelauna, PR #1043 by @app/zoomote) +- Keep Architect mode plans workspace-relative so creating `./plans` does not fail on read-only filesystem roots (#965 by @juneleung, PR #968 by @app/zoomote) +- Replace remaining user-facing Roo branding with Zoo branding (#551 by @proyectoauraorg, PR #971 by @rrewll) +- Introduce a semaphore-based task scheduler for controlled task execution (#358 by @edelauna, PR #1031 by @edelauna) +- Introduce `TaskRegistry` and migrate task-stack management to it (#367 by @edelauna, PR #1014 by @edelauna) +- Complete the API provider migration to canonical provider identifiers (#955 by @WebMad, PR #1012 by @WebMad) +- Use canonical provider identifiers for default model definitions (#954 by @WebMad, PR #991 by @WebMad) +- Use canonical provider identifiers for API model caches (#957 by @WebMad, PR #1020 by @WebMad) +- Use canonical provider identifiers in shared profile settings (#956 by @WebMad, PR #1019 by @WebMad) +- Use canonical provider identifiers throughout extension-core flows (#958 by @WebMad, PR #1022 by @WebMad) +- Use canonical provider identifiers throughout the webview (#959 by @WebMad, PR #1023 by @WebMad) +- Finish the repository-wide canonical provider identifier audit (#960 by @WebMad, PR #1030 by @WebMad) +- Centralize API service-tier types and helpers (PR #1040 by @WebMad) +- Support the platform-package layout introduced by `@vscode/ripgrep` 1.18 and later (#1024 by @saravanaraj0078-lab, PR #1032 by @edelauna) +- Update `shell-quote` to 1.9.0 to incorporate security fixes (PR #986 by @app/renovate) +- Update the development and build toolchain to Node.js 22.23.1 LTS (PR #743 by @app/renovate) +- Add a Playwright visual-regression harness for webview components (#515 by @edelauna, PR #526 by @edelauna) +- Enforce the no-floating-promises lint ratchet in `core/webview` (#949 by @edelauna, PR #950 by @morgan-coded) +- Harden the end-to-end workflow against transient VS Code binary download failures (#1044 by @edelauna, PR #1045 by @app/zoomote) +- Isolate mocked subtask fixtures to prevent flaky parent-resume tests (#1001 by @app/zoomote, PR #1002 by @app/zoomote) +- Prevent cancelled delayed mock streams from leaking between subtask end-to-end tests (PR #1074 by @app/zoomote) +- Merge the v3.72.0 release preparation branch into `main` (PR #1013 by @navedmerchant) + +## [3.72.0] + +### Minor Changes + +- Add the Moonshot provider with live model discovery, streaming, model metadata, and a model picker (PR #857 by @grizmin) +- Add the Kimi Code provider with OAuth device-flow authentication (PR #945 by @taltas) +- Add Claude Opus 5 support across all providers (PR #1010 by @app/zoomote) +- Add Kimi K3 to the Moonshot and OpenCode Go providers (#932 by @navedmerchant, PR #996 by @app/zoomote) +- Add Gemini 3.6 Flash model support (PR #975 by @app/zoomote) +- Add MiniMax-M3 model support (#888 by @RayWinter0816, PR #946 by @app/zoomote) +- Add a safe way to abandon interrupted subtasks by severing stale parent-child links and surfacing delegation status (#559 by @edelauna, PR #935 by @edelauna) +- Add Dart support to codebase indexing (#940 by @WebMad, PR #941 by @WebMad) +- Fix codebase indexing for plain-text files (#931 by @tool-buddy, PR #938 by @WebMad) +- Enable image input for DeepSeek V4 models (#964 by @grizmin, PR #963 by @grizmin) +- Fix ChatGPT OAuth requests for GPT-5.6 Luna being rejected by the Codex backend (PR #889 by @taltas) +- Preserve `reasoning_content` for known reasoning model families when using LiteLLM (#891 by @daewoongoh, PR #899 by @daewoongoh) +- Fix task-history cache invalidation races by routing `invalidate()` and `invalidateAll()` through the task-history lock (#698 by @edelauna, PR #912 by @morgan-coded) +- Fix Settings mode changes by synchronizing the local `cachedState` editing buffer (#914 by @easonLiangWorldedtech, PR #925 by @easonLiangWorldedtech) +- Dismiss the welcome screen after successful Zoo Gateway sign-in (#961 by @JohnCanty, PR #962 by @JamesRobert20) +- Add `CompletePromptOptions` to the `completePrompt` API so callers can configure prompt completion (#615 by @edelauna, PR #901 by @easonLiangWorldedtech) +- Centralize provider identifiers into canonical shared types (#951 by @WebMad, PR #952 by @WebMad) +- Refactor provider categories to use canonical provider identifiers (PR #989 by @WebMad) +- Remove obsolete MCP server-creation translations (#895 by @edelauna, PR #943 by @WebMad) +- Add regression coverage for resuming interrupted subtasks (#566 by @myk1yt, PR #911 by @edelauna) +- Upload coverage reports as GitHub Actions artifacts to simplify CI debugging (PR #939 by @app/zoomote) +- Update `esbuild-wasm` to v0.28.1 (PR #829 by @app/renovate) + +## [3.70.0] + +### Minor Changes + +- Add Kenari as a first-class provider, an Indonesian OpenAI-compatible AI gateway billed in Rupiah covering Claude, GPT, DeepSeek, GLM, Kimi and more (#792 by @doedja, PR #793 by @doedja) +- Add OpenAI GPT-5.6 family support (sol, terra, luna) across both OpenAI Codex and OpenAI Native provider paths (#871 by @xRaTcHeT302, PR #876 by @navedmerchant) +- Add Grok 4.5 support and fix a latent xAI reasoning-effort format bug affecting Grok 4 Mini (#866 by @navedmerchant, PR #867 by @navedmerchant) +- Surface the context-compaction button and context window progress bar in the collapsed task header so context pressure can be monitored and acted on without expanding it (#606 by @awschmeder, PR #680 by @awschmeder) +- Fix(terminal): fix output loss and premature task completion on cold terminals by reading command output only after shell execution actually starts (#800 by @juneleung, PR #834 by @edelauna) +- Fix(zoo-gateway): enable image attach for Zoo Gateway and Vercel AI Gateway models based on live vision-capability tags instead of a static allowlist (PR #897 by @JamesRobert20) +- Chore(deps): routine dependency updates (PRs #807-#828 by @app/renovate) + +## [3.68.0] + +### Minor Changes + +- Add Friendli provider with GLM-5.2 support for another hosted way to use the latest GLM model (#722 by @Lee-Si-Yoon, PR #721 by @Lee-Si-Yoon) +- Add native thinking/reasoning support for Ollama models to preserve reasoning output end-to-end (#831 by @navedmerchant, PR #832 by @navedmerchant) +- Fix(anthropic): honor custom `apiModelId` selections instead of silently defaulting to `claude-sonnet-4-5` (#418 by @tatianadenel-devops, #843 by @grizmin, PR #842 by @grizmin) +- Fix(ollama): correctly handle tool results and prevent premature context condensing (#847 by @navedmerchant, PR #848 by @navedmerchant) +- Improve Anthropic Vertex Claude content block handling for more reliable responses (#788 by @daewoongoh, PR #789 by @daewoongoh) +- Fix(task-lifecycle): preserve the parent-child link when a delegated subtask is interrupted (#560 by @edelauna, PR #787 by @edelauna) +- Refactor: remove the deprecated `openai-error-handler` shim and use the shared `error-handler` directly (#766 by @daewoongoh, PR #767 by @daewoongoh) +- Feat(nightly-publish): publish Open VSX pre-releases and skip nightly publish on release merges (#784 by @edelauna, PR #790 by @edelauna) +- Fix(ci): don't skip fork-PR label reconciliation on scheduled and manual runs (PR #234 by @app/roomote) +- Fix(label-pr-review-state): detect merge conflicts and label PRs with `has-conflicts` (PR #269 by @app/roomote) +- Chore(deps): update the `github/codeql-action` digest to `411c4c9` (PR #803 by @app/renovate) +- Chore(deps): update `@types/react` to `v18.3.31` (PR #805 by @app/renovate) +- Chore(deps): update `axios` to `v1.18.1` (PR #806 by @app/renovate) +- Chore: merge the v3.66.0 release preparation branch into `main` (PR #795 by @navedmerchant) + +## [3.66.0] + +### Minor Changes + +- Add Claude Sonnet 5 support across Anthropic, Bedrock, and Vertex providers (#777 by @navedmerchant, PR #778 by @navedmerchant) +- Upgrade Semble to v0.4.1 with flattened result parsing and localized status messages (#733 by @navedmerchant, PR #734 by @navedmerchant) +- Add task-lifecycle status transition guard and startup delegation reconciliation to prevent invalid task state transitions (#366 by @edelauna, PR #692 by @edelauna) +- Fix: LiteLLM cache key collision and silent fallback to a non-existent default model (#638 by @awschmeder, PR #647 by @awschmeder) +- Fix: reliable auto context condensing for the VS Code Language Model API (#714 by @simurg79, PR #710 by @simurg79) +- Fix(ThinkingBudget): support `xhigh` and all extended reasoning effort values (#713 by @6rz6, PR #774 by @edelauna) +- Fix(deepseek): round-trip `reasoning_content` in thinking mode to prevent 400 errors (#201 by @leosdad, PR #775 by @edelauna) +- Fix(gemini): base64-encode `thoughtSignature` bypass token to fix the Vertex AI empty-response loop (#536 by @edelauna, PR #776 by @edelauna) +- Fix: provider cache reset after settings import (#689 by @JunyongParkDev, PR #726 by @JunyongParkDev) +- Fix(delegation): atomically serialize `reopenParentFromDelegation` (#365 by @edelauna, PR #725 by @edelauna) +- Fix: shell default profile name type guard (#686 by @daewoongoh, PR #687 by @daewoongoh) +- chore(security): dependency-review, invisible-char detection, and least-privilege workflow permissions (#782 by @edelauna, PR #783 by @edelauna) +- chore: upgrade `@anthropic-ai/sdk` to 0.104.1 and `@anthropic-ai/vertex-sdk` to 0.17.1 (#438 by @p12tic, PR #600 by @p12tic) +- chore: enforce no-floating-promises in core/task/ (PR #253 by @0xMink) +- ci: improve PR label reconciliation with CI gating and event triggers (PR #228 by @app/roomote) +- fix(deps): update AI SDKs and providers (PR #744 by @app/renovate) +- chore(deps): update build, lint, and test tooling (PR #745 by @app/renovate) +- chore(deps): update dependency mermaid to v11.16.0 (PR #742 by @app/renovate) +- chore(deps): update dependency posthog-js to v1.393.5 (PR #746 by @app/renovate) +- chore(deps): update dependency ajv to v8.20.0 (PR #747 by @app/renovate) +- chore(deps): update dependency react-use to v17.6.1 (PR #740 by @app/renovate) +- chore(deps): update dependency reconnecting-eventsource to v1.6.5 (PR #741 by @app/renovate) +- chore(deps): update dependency pdf-parse to v1.1.4 (PR #739 by @app/renovate) +- chore(deps): update dependency ovsx to v0.10.12 (PR #738 by @app/renovate) +- chore(deps): update dependency only-allow to v1.2.2 (PR #737 by @app/renovate) + +## [3.64.0] + +### Minor Changes + +- Add Rules Management UI — new Rules tab in Settings to create, delete, and open global and workspace Zoo rules (#660 by @ivanarifin, PR #657 by @ivanarifin) +- Add completion change review actions — "See New Changes" and "Restore Changes" buttons after task completion let you inspect and undo changes from the latest prompt (#661 by @ivanarifin, PR #633 by @ivanarifin) +- Add kimi-k2p7-code model on Fireworks provider (PR #599 by @p12tic) +- feat: add abort signal core plumbing — threads AbortSignal through the API metadata layer for future provider-level cancellation (#434 by @easonLiangWorldedtech, PR #674 by @easonLiangWorldedtech) +- feat: add TaskSemaphore utility for parallel task coordination (#362 by @edelauna, PR #675 by @edelauna) +- feat(experiments): register PARALLEL_TOOL_EXECUTION feature flag (internal-only) (#363 by @edelauna, PR #678 by @edelauna) +- Add Roo Code history import to the About page (PR #141 by @roomote) +- Fix: configurable relaxed diff thresholds and diagnostics reduce "edit unsuccessful" errors (#452 by @DannyVarodBlueVine, PR #470 by @nigeldelviero) +- Fix: auto-closing edited files is now opt-in — the setting defaults to off (#719 by @edelauna, PR #720 by @edelauna) +- Fix(diff-view): make auto-closing edited files opt-in, fixing setting that could not be unchecked (#667 by @navedmerchant, PR #668 by @navedmerchant) +- Fix(delegation): serialize delegateParentAndOpenChild with atomicReadAndUpdate to prevent race conditions (#364 by @edelauna, #365 by @edelauna, PR #691 by @edelauna) +- Fix(ask_followup_question): report non-array follow_up suggestions as a type error (#511 by @nh2, PR #662 by @nh2) +- Fix: parse Gemma 4 `` reasoning tags alongside `` (#323 by @sagidM, PR #324 by @sagidM) +- docs(prompt): enhance apply_diff tool instructions to improve Gemini model success rate (#611 by @awschmeder, PR #619 by @awschmeder) +- chore(deps): update undici to v6.27.0 [security] (PR #659 by @renovate) +- chore(deps): update @types/node, @vscode/test-cli, execa, axios (PR #669, #670, #671, #673 by @renovate) +- test(mcp): fix McpHub Windows command wrapping test ordering (PR #632 by @HappyLiang12) +- fix(McpHub): resolve flaky McpHub.spec.ts tests after Vitest 4 upgrade (PR #666 by @edelauna) + +## [3.62.0] + +### Minor Changes + +- Add GLM-5.2 support — the latest GLM model is now available in your provider settings (#597 by @percy4, PR #608 by @MobCode100) +- Add OpenCode-Go native model parameters, Anthropic-format routing, and context-token fix for more reliable responses (#646 by @ykoneee, PR #652 by @navedmerchant) +- Add tool-writer mode to the Marketplace — a new specialized mode for writing and maintaining tool definitions (#603 by @RayCarro, PR #604 by @RayCarro) +- Add LiteLLM support for forwarding taskId as X-Zoo-Session-ID request header for better request tracing (#590 by @awschmeder, PR #591 by @awschmeder) +- Fix: Apply apiRequestTimeout consistently across all providers (#565 by @daewoongoh, PR #567 by @daewoongoh) +- Fix: Restore diff view scroll position and fix tab handling on save/deny (#586 by @awschmeder, PR #589 by @awschmeder) +- Fix: Deliver terminal completion signal when end event wins the race against setActiveStream (#489 by @drzraf, #622 by @onlineapps-cloud, PR #645 by @edelauna) +- Fix: Fetch OpenCode-Go models unconditionally — the /models endpoint is public (PR #437 by @proyectoauraorg) +- Refactor: Extract RateLimitClock from Task static state for cleaner rate-limit handling (#361 by @edelauna, PR #628 by @edelauna) +- Refactor: Use extractReasoningFromDelta helper for reasoning extraction across providers (PR #588 by @daewoongoh) +- Fix: Automate PR review-state and stale labels in GitHub Actions (PR #636 by @edelauna) +- Re-enable the prefer-const ESLint rule (PR #250 by @0xMink) +- Add stale PR workflows and auto-closure policy (PR #631 by @edelauna) +- Update dependency vitest to v4 [security] (PR #443 by @app/renovate) +- Update dependency shell-quote to v1.8.4 [security] (PR #554 by @app/renovate) +- Update dependency esbuild to v0.28.1 [security] (PR #595 by @app/renovate) +- Update dependency vite to v8.0.16 [security] (PR #642 by @app/renovate) +- Update GitHub Actions (PR #521 by @app/renovate) + +## [3.60.0] + +### Minor Changes + +- Add Claude Fable 5 support across Anthropic, Bedrock, and Vertex providers (PR #555 by @taltas) +- Add OpenAI GPT-5.5 support (PR #537 by @scream4ik) +- Add per-mode MCP server restrictions — configure an allowlist to restrict which MCP servers are active per mode (PR #453 by @simurg79) +- Add workspace `rootResolution` setting for controlling path resolution in multi-root workspaces (PR #538 by @simurg79) +- Add LiteLLM support for `reasoning_content` and `reasoning` fields in streaming responses (PR #449 by @daewoongoh) +- Add Show Ripgrep Diagnostic command for easier ripgrep troubleshooting (PR #281 by @0xMink) +- Redesign terminal profile settings UX — unified dropdown, consistent layout, and improved styling (#119 by @chenyuanrun, #321 by @F915, PR #533 by @F915) +- Fix chat window running out of memory when transcript grows large (PR #153 by @app/roomote) +- Fix relative symlinks in rules files not resolving correctly using realpath of parent directory (PR #442 by @p12tic) +- Fix command approval buttons not clearing when auto-executed (PR #480 by @awschmeder) +- Fix multi-line quoted command parsing, auto-approval behavior, and malformed-command error surfacing (PR #483 by @awschmeder) +- Fix `list-files` tool to validate directory exists before spawning ripgrep (#557 by @edelauna, PR #558 by @edelauna) +- Fix child tasks returning to parent when parent status is active in AttemptCompletionTool (PR #510 by @edelauna) +- Fix surface in-stream errors from Zoo and Vercel AI gateways (PR #569 by @JamesRobert20) +- Gate marketplace publish behind PR approval check (PR #516 by @edelauna) +- Stabilize flaky e2e provider suite ordering and zai requestCapture race (#512 by @edelauna, #514 by @edelauna, PR #45 by @app/roomote) +- Fix flaky e2e subtasks fixture collision and task identity prompt (#561, PR #563 by @simurg79) +- Add contributing guidelines: PR expectations and AI-assisted contribution policy (PR #562 by @edelauna) +- Configure knip and remove dead code (PR #225 by @app/roomote) +- Pin dependencies (PR #423 by @app/renovate) + +## [3.58.1] + +### Patch Changes + +- Fix: Remove unsupported `--no-absolute-filenames` tar argument (#491 by @kazenshi, PR #492 by @kazenshi) + +## 3.58.0 + +### Minor Changes + +- Add Zoo Gateway provider with auth callback and multi-profile token sync (PR #344 by @JamesRobert20, PR #345 by @JamesRobert20, PR #347 by @JamesRobert20) +- Add Gemini 3.5 Flash support (PR #331 by @jeanbispo) +- Add Semble as a local on-the-fly embedding provider for code indexing (PR #399 by @navedmerchant) +- Remove extension-side LLM telemetry; server logs only through gateway (PR #346 by @JamesRobert20) +- Add VS Code integrated terminal shell override (PR #277 by @proyectoauraorg) +- Add configurable chat font size (#157 by @duvw, PR #276 by @proyectoauraorg) +- Render GitHub-style alerts in the webview (#258 by @melck, PR #275 by @proyectoauraorg) +- Add configurable max output tokens for GLM models (#161 by @app/roomote, PR #274 by @proyectoauraorg) +- Introduce WorkspacePathResolver for async symlink-aware path canonicalization (#389 by @edelauna, PR #428 by @proyectoauraorg) +- Better secure release workflows and GitHub Actions (PR #482 by @edelauna) +- Fix React crash from malformed follow-up suggestion mode (PR #414 by @edelauna) +- Fix OpenAI temperature omitted when no custom value is set (#242 by @brunocasado, PR #247 by @proyectoauraorg) +- Handle per-key failures during settings import (PR #401 by @taltas) +- Add comprehensive test coverage for ReadFileTool (PR #222 by @proyectoauraorg) +- Unskip VS Code e2e replay for subtasks (PR #94 by @app/roomote) +- Fix e2e cache: replace paths filter with content-hash cache skip (PR #268 by @app/roomote) +- Remove deprecated requestRooCreditBalance handler (PR #385 by @JamesRobert20) +- Update mermaid to v11.15.0 for a security fix (PR #235 by @app/renovate) +- Update axios to v1.16.0 for a security fix (PR #400 by @app/renovate) +- Pin dependencies (PR #353 by @app/renovate) +- Remove unused tmp dependency and other unused packages (PR #341 by @app/renovate) + +## 3.56.0 + +### Minor Changes + +- Add Claude Opus 4.8 support across Anthropic, Bedrock, and Vertex providers (PR #386 by @vandre-sales) +- Add Opencode Go as a first-class provider (#172 by @vijay-0001, PR #319 by @proyectoauraorg) +- Add glm-5.1, kimi-k2.6, and deepseek-v4-pro models to the Fireworks provider (#198 by @DeCodeTheWeb, PR #231 by @proyectoauraorg) +- Show Zoo Code identity in outbound provider activity logs (#203 by @yfdyh000, PR #219 by @app/roomote) +- Fix API requests hanging indefinitely on VS Code 1.122.0+ (#381 by @greatgradz-svg, #382 by @abcxlab, PR #383 by @app/roomote) +- Fix terminal task cancellation so the running process is terminated when a task is cancelled (#245 by @proyectoauraorg, PR #261 by @proyectoauraorg) +- Fix terminal Ctrl+C retry so processes that need multiple SIGINT signals are properly stopped (#266 by @edelauna, PR #272 by @proyectoauraorg) +- Fix Gemini provider to honor custom model IDs instead of falling back to the default (#227 by @notoccupy2023-design, PR #317 by @proyectoauraorg) +- Fix truncated Grok diffs caused by missing diff markers (#186 by @jcalfee, PR #230 by @proyectoauraorg) +- Fix PowerShell detection on Windows when no shell profile is configured (#82 by @rossdonald, PR #239 by @proyectoauraorg) +- Fix Vertex AI warning when the Google Cloud Credentials field receives a file path instead of JSON (PR #294 by @0xMink) +- Rename Zoo Code in VS Code code actions (#328 by @rrewll, PR #329 by @rrewll) +- Localize VS Code code action commands (#334 by @edelauna, PR #339 by @rrewll) +- Migrate webview build to Vite 8 (PR #214 by @maxdewald) +- Add comprehensive unit tests for AskFollowupQuestionTool and ListFilesTool (#206 by @app/roomote, PR #212, #213 by @proyectoauraorg) +- Update `diff` to v5.2.2 for a security fix (PR #173 by @app/renovate) +- Update `i18next-http-backend` to v3.0.5 for a security fix (PR #174 by @app/renovate) +- Update `fast-xml-parser` to v5.7.0 for a security fix (PR #179 by @app/renovate) +- Update `simple-git` to v3.36.0 for a security fix (PR #182 by @app/renovate) +- Update `uuid` and pin esbuild/rollup/vite for a security fix (PR #205 by @app/renovate) +- Update `turbo` to v2.9.14 for a security fix (PR #236 by @app/renovate) + +## 3.55.1 + +### Patch Changes + +- Fix API requests hanging indefinitely on VS Code 1.122.0+ when Zoo Code could not find the bundled ripgrep binary after the `@vscode/ripgrep-universal` rename (#381 by @greatgradz-svg, PR #248 by @0xMink). + +All notable changes to Zoo Code will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Zoo Code uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 3.55.0 + +### Minor Changes + +- Add Xiaomi MiMo as a first-class API provider (#80 by @capitanfeeder, PR #81 by @capitanfeeder) +- Merge the Roo Code upstream sunset into Zoo Code and pull in related handoff updates (PR #123 by @edelauna) +- Fix Gemini requests when users enable the full MCP tool set (PR #148 by @app/roomote) +- Fix OpenAI requests by omitting temperature for models that do not support it (#215 by @marty-a11y, PR #233 by @proyectoauraorg) +- Fix the MCP OAuth callback page garbled text after sign-in (#217 by @mabiuroot-art, PR #218 by @app/roomote) +- Fix single-tilde Markdown so normal text no longer appears struck through (#154 by @slashedstar, PR #240 by @proyectoauraorg) +- Add deterministic xAI provider end-to-end coverage (PR #149 by @app/roomote) +- Update the default Z.AI model to GLM-4.7 (PR #90 by @bryce-hoehn) +- Fix GLM models reserving too much output context by default (PR #160 by @app/roomote) +- Fix the Vertex AI region dropdown so eu and us multi-region endpoints appear correctly (PR #170 by @app/roomote) +- Fix the webview diagnostics temp file prefix (#193 by @proyectoauraorg, PR #226 by @proyectoauraorg) +- Fix Shift+Enter sending chat messages when Ctrl/Cmd+Enter mode is enabled (PR #199 by @app/roomote) +- Fix recursive `list_files` omitting nested files in temp workspaces (PR #91 by @app/roomote) +- Fix the welcome screen (#162 by @navedmerchant, PR #163 by @navedmerchant) +- Refactor core monolith helpers (PR #27 by @doctarock) +- Unskip read-file tests (PR #53 by @edelauna) +- Improve core coverage CI and merge queue readiness (PR #207 by @app/roomote) +- Unskip the VS Code e2e replay for mutating tools (PR #92 by @app/roomote) +- Unskip the VS Code e2e replay for `use_mcp_tool` (PR #93 by @app/roomote) +- Reduce Renovate review noise (PR #144 by @app/roomote) +- Let Renovate open grouped updates on the normal bot cadence (PR #167 by @app/roomote) +- Add comprehensive unit tests for the MiMoHandler provider (PR #210 by @proyectoauraorg) +- Add unit tests for the SwitchModeTool (PR #211 by @proyectoauraorg) +- Update `yaml` to `2.8.3` for a security fix (PR #176 by @app/renovate) +- Update `axios` to `1.15.2` for a security fix (PR #177 by @app/renovate) +- Update `mammoth` to `1.11.0` for a security fix (PR #180 by @app/renovate) +- Update `undici` to `6.24.0` for a security fix (PR #183 by @app/renovate) +- Update `@ai-sdk/amazon-bedrock` to `4.0.107` (PR #55 by @app/renovate) +- Update `@ai-sdk/baseten` to `1.0.50` (PR #56 by @app/renovate) + +## 3.54.1 + +### Patch Changes + +- Fix Anthropic Opus 4.7 when reasoning is enabled (PR #111 by @app/roomote) +- Fix the OpenAI Compatible onboarding form starting above the viewport (PR #113 by @app/roomote) +- Fix settings and Marketplace access after importing Roo Router settings (PR #109 by @app/roomote) +- Fix the setup announcement origin and load LM Studio models on first open (PR #97 by @app/roomote) +- Fix Discord invite links that still pointed to the old Zoo Code server (PR #107 by @app/roomote) +- Fix support links that opened the wrong GitHub repository (PR #77 by @app/roomote) +- Refresh Zoo Code branding across docs and metadata (PR #85 by @taltas) +- Clarify Zoo Code migration messaging in the README (PR #99 by @taltas) +- Keep settings regression coverage in the webview-ui test suite (PR #95 by @app/roomote) +- Clean up skipped extension package tests (PR #110 by @app/roomote) +- Add DeepSeek V4 end-to-end coverage (PR #72 by @app/roomote) +- Use repo collaborators as the default code owners (PR #96 by @app/roomote) +- Use a single PR flow for extension releases (PR #142 by @app/roomote) +- Update `isbinaryfile` to `5.0.7` (PR #88 by @f14XuanLv) +- Update `@dotenvx/dotenvx` to `1.66.0` (PR #61 by @app/renovate) +- Update `lint-staged` to `16.4.0` (PR #64 by @app/renovate) +- Update Node.js to `20.20.2` (PR #65 by @app/renovate) + +## [3.54.0] - 2026-05-08 + +### Added + +- Publish Zoo Code under the `ZooCodeOrganization.zoo-code` Marketplace identity, continuing from upstream Roo Code `3.53.0`. +- Add stable publishing workflows for the VS Code Marketplace and Open VSX Registry. +- Add a VS Code Marketplace pre-release workflow. + +--- + +# Archived Roo Code Changelog + +The entries below are preserved from the upstream Roo Code project history before the Zoo Code marketplace handoff. + +## 3.53.0 + +### Minor Changes + +- **The Roo Code plugin is not going away.** You may have seen the [recent announcement](https://x.com/mattrubens/status/2046636598859559114) that Roo Code hit 3 million installs and the original team is going all-in on Roomote. We know that news was hard for a lot of you. This plugin means a lot to us and to you, and we hear you. The good news: a community team has stepped up to carry Roo Code forward, and we're working with them on an official handoff so the plugin you rely on keeps getting maintained and improved. +- Add GPT-5.5 support via the OpenAI Codex provider (PR #12170 by @hannesrudolph) +- Add Claude Opus 4.7 support on Vertex AI (#12134 by @saneroen, PR #12135 by @saneroen) +- Add previous checkpoint navigation controls and i18n in chat (#12138 by @saneroen, PR #12139 by @saneroen) +- Add Roomote banner (PR #12119 by @brunobergher) +- Redesign Roomote announcement banner with violet branding on the web (PR #12161 by @roomote-v0) +- Add sunsetting Roo Code blog post (PR #12160 by @roomote-v0) + +## 3.52.1 + +### Patch Changes + +- Add correct JSON schema for `.roomodes` configuration files (#11790 by @algorhythm85, PR #11791 by @app/roomote-v0) +- Remove the hiring announcement from the VS Code extension UI (PR #12108 by @app/roomote-v0) + +## 3.52.0 + +### Minor Changes + +- Add Poe as an AI provider so users can access Poe models directly in Roo Code (PR #12015 by @kamilio) +- Improve the xAI provider by migrating it to the Responses API with reusable transform utilities (#11961 by @carlesso, PR #11962 by @carlesso) +- Fix MiniMax model listings and context window handling for more reliable configuration (#11999 by @Rexarrior, PR #12069 by @Rexarrior) +- Add xAI Grok-4.20 models and update the default xAI model selection (#11955 by @carlesso, PR #11956 by @carlesso) +- Add OpenAI GPT-5.4 mini and nano models to expand the available OpenAI model lineup (PR #11946 by @PeterDaveHello) +- Chore: include the automated version bump PR from the previous release cycle for complete release accounting (PR #11892 by @app/github-actions) + +### Patch Changes + +- Add support for OpenAI `gpt-5.4-mini` and `gpt-5.4-nano` models. + +## 3.51.1 + +### Patch Changes + +- Feat: Add Cohere Embed v4 model support for Bedrock and improve credential handling (#11823 by @cscvenkatmadurai, PR #11824 by @cscvenkatmadurai) +- Feat: Add Gemini 3.1 Pro customtools model to Vertex AI provider (PR #11857 by @NVolcz) +- Feat: Add gpt-5.4 to ChatGPT Plus/Pro (Codex) model catalog (PR #11876 by @roomote-v0) + +## 3.51.0 + +### Minor Changes + +- Add OpenAI GPT-5.4 and GPT-5.3 Chat Latest model support so Roo Code can use the newest OpenAI chat models (PR #11848 by @PeterDaveHello) +- Add support for exposing skills as slash commands with skill fallback execution for faster workflows (PR #11834 by @hannesrudolph) +- Add CLI support for `--create-with-session-id` plus UUID session validation for more controlled session creation (PR #11859 by @cte) +- Add support for choosing a specific shell when running terminal commands (PR #11851 by @jr) +- Feature: Add the `ROO_ACTIVE` environment variable to terminal session settings for safer terminal guardrails (#11864 by @ajjuaire, PR #11862 by @ajjuaire) +- Improve cloud settings freshness by updating the refresh interval to one hour (PR #11749 by @roomote-v0) +- Add CLI session resume/history support plus an upgrade command for better long-running workflows (PR #11768 by @cte) +- Add support for images in CLI stdin stream commands (PR #11831 by @cte) +- Include `exitCode` in CLI command `tool_result` events for more reliable automation (PR #11820 by @cte) +- Add CLI types to improve development ergonomics and type safety (PR #11781 by @cte) +- Add CLI integration coverage for stdin stream routing and race-condition invariants (PR #11846 by @cte) +- Fix the CLI stdin-stream cancel race and add an integration test suite to prevent regressions (PR #11817 by @cte) +- Improve CLI stream recovery and add a configurable consecutive mistake limit (PR #11775 by @cte) +- Fix CLI streaming deltas, task ID propagation, cancel recovery, and other runtime edge cases (PR #11736 by @cte) +- Fix CLI task resumption so paused work can reliably continue (PR #11739 by @cte) +- Recover from unhandled exceptions in the CLI instead of failing hard (PR #11750 by @cte) +- Scope CLI session and resume flags to the current workspace to avoid cross-workspace confusion (PR #11774 by @cte) +- Fix stdin prompt streaming to forward task configuration correctly (PR #11778 by @daniel-lxs) +- Handle stdin-stream control-flow errors gracefully in the CLI runtime (PR #11811 by @cte) +- Fix stdin stream queued messages and command output streaming in the CLI (PR #11814 by @cte) +- Increase the CLI command execution timeout for long-running commands (PR #11815 by @cte) +- Fix knip checks to keep repository validation green (PR #11819 by @cte) +- Fix CLI upgrade version detection so upgrades resolve the correct target version (PR #11829 by @cte) +- Ignore model-provided timeout values in the CLI runtime to keep command handling consistent (PR #11835 by @cte) +- Fix redundant skill reloading during conversations to reduce duplicate work (PR #11838 by @hannesrudolph) +- Ensure full command output is streamed before the CLI reports completion (PR #11842 by @cte) +- Fix CLI follow-up routing after completion prompts so next actions land in the right place (PR #11844 by @cte) +- Remove the Netflix logo from the homepage (PR #11787 by @roomote-v0) +- Chore: Prepare CLI release v0.1.2 (PR #11737 by @cte) +- Chore: Prepare CLI release v0.1.3 (PR #11740 by @cte) +- Chore: Prepare CLI release v0.1.4 (PR #11751 by @cte) +- Chore: Prepare CLI release v0.1.5 (PR #11772 by @cte) +- Chore: Prepare CLI release v0.1.6 (PR #11780 by @cte) +- Release Roo Code v1.113.0 (PR #11782 by @cte) +- Chore: Prepare CLI release v0.1.7 (PR #11812 by @cte) +- Chore: Prepare CLI release v0.1.8 (PR #11816 by @cte) +- Chore: Prepare CLI release v0.1.9 (PR #11818 by @cte) +- Chore: Prepare CLI release v0.1.10 (PR #11821 by @cte) +- Release Roo Code v1.114.0 (PR #11822 by @cte) +- Chore: Prepare CLI release v0.1.11 (PR #11832 by @cte) +- Release Roo Code v1.115.0 (PR #11833 by @cte) +- Chore: Prepare CLI release v0.1.12 (PR #11836 by @cte) +- Chore: Prepare CLI release v0.1.13 (PR #11837 by @hannesrudolph) +- Chore: Prepare CLI release v0.1.14 (PR #11843 by @cte) +- Chore: Prepare CLI release v0.1.15 (PR #11845 by @cte) +- Chore: Prepare CLI release v0.1.16 (PR #11852 by @cte) +- Chore: Prepare CLI release v0.1.17 (PR #11860 by @cte) + +### Patch Changes + +- Add OpenAI's GPT-5.3-Chat-Latest model support +- Add OpenAI's GPT-5.3-Codex model support +- Add OpenAI's GPT-5.4 model support +- Add OpenAI's GPT-5.3-Codex model support (PR #11728 by @PeterDaveHello) +- Warm Roo models on CLI startup for faster initial responses (PR #11722 by @cte) +- Fix spelling/grammar and casing inconsistencies (#11478 by @PeterDaveHello, PR #11485 by @PeterDaveHello) +- Fix: Restore Linear integration page (PR #11725 by @roomote) +- Chore: Prepare CLI release v0.1.1 (PR #11723 by @cte) + +## [3.50.4] - 2026-02-21 + +- Feat: Add MiniMax M2.5 model support (#11471 by @love8ko, PR #11458 by @roomote) + +## [3.50.3] - 2026-02-20 + +- Fix: Correct Vertex AI claude-sonnet-4-6 model ID (#11625 by @yuvarajl, PR #11626 by @roomote) +- Restore Unbound as a provider (PR #11624 by @pugazhendhi-m) + +## [3.50.2] - 2026-02-20 + +- Fix: Inline terminal rendering parity with the VSCode Terminal (#10699 by @jerrill-johnson-bitwerx, PR #11361 by @RussellZager) +- Fix: Enable prompt caching for Bedrock custom ARN and default to ON (#10846 by @wisestmumbler, PR #11373 by @roomote) +- Feat: Add visual feedback to copy button in task actions (#11401 by @omagoduck, PR #11403 by @omagoduck) + +## [3.50.1] - 2026-02-20 + +- Fix OpenAI Codex and OpenAI Native stream parsing for done-only and `content_part` events, including duplicate-text guards when deltas are already streamed. + +## [3.50.0] - 2026-02-19 + +- Add Gemini 3.1 Pro support and set as default Gemini model (PR #11608 by @PeterDaveHello) +- Add NDJSON stdin protocol, list subcommands, and modularize CLI run command (PR #11597 by @cte) +- Prepare CLI v0.1.0 release (PR #11599 by @cte) +- Remove integration tests (PR #11598 by @roomote) +- Changeset version bump (PR #11596 by @github-actions) + +## [3.49.0] - 2026-02-19 + +- Add file changes panel to track all file modifications per conversation (#11493 by @saneroen, PR #11494 by @saneroen) +- Add per-workspace indexing opt-in and stop/cancel indexing controls (#11455 by @JamesRobert20, PR #11456 by @JamesRobert20) +- Add per-task file-based history store for cross-instance safety (PR #11490 by @roomote) +- Fix: Redesign rehydration scroll lifecycle for smoother chat experience (PR #11483 by @hannesrudolph) +- Fix: Bump @roo-code/types metadata version to 1.111.0 after revert regression (PR #11588 by @roomote) + +## [3.48.1] - 2026-02-18 + +- Fix: Await MCP server initialization before returning McpHub instance, preventing race conditions (PR #11518 by @daniel-lxs) +- Fix: Correct Bedrock Claude Sonnet 4.6 model ID (#11509 by @PeterDaveHello, PR #11569 by @PeterDaveHello) +- Add DeleteQueuedMessage IPC command for managing queued messages (PR #11464 by @roomote) + +## [3.48.0] - 2026-02-17 + +- Add Anthropic Claude Sonnet 4.6 support across all providers — Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway (PR #11509 by @PeterDaveHello) +- Add lock toggle to pin API config across all modes in a workspace (PR #11295 by @hannesrudolph) +- Fix: Prevent parent task state loss during orchestrator delegation (PR #11281 by @hannesrudolph) +- Fix: Resolve race condition in new_task delegation that loses parent task history (PR #11331 by @daniel-lxs) +- Fix: Serialize taskHistory writes and fix delegation status overwrite race (PR #11335 by @hannesrudolph) +- Fix: Prevent chat history loss during cloud/settings navigation (#11371 by @SannidhyaSah, PR #11372 by @SannidhyaSah) +- Fix: Preserve condensation summary during task resume (#11487 by @SannidhyaSah, PR #11488 by @SannidhyaSah) +- Fix: Resolve chat scroll anchoring and task-switch scroll race conditions (PR #11385 by @hannesrudolph) +- Fix: Preserve pasted images in chatbox during chat activity (PR #11375 by @app/roomote) +- Add disabledTools setting to globally disable native tools (PR #11277 by @daniel-lxs) +- Rename search_and_replace tool to edit and unify edit-family UI (PR #11296 by @hannesrudolph) +- Render nested subtasks as recursive tree in history view (PR #11299 by @hannesrudolph) +- Remove 9 low-usage providers and add retired-provider UX (PR #11297 by @hannesrudolph) +- Remove browser use functionality entirely (PR #11392 by @hannesrudolph) +- Remove built-in skills and built-in skills mechanism (PR #11414 by @hannesrudolph) +- Remove footgun prompting (file-based system prompt override) (PR #11387 by @hannesrudolph) +- Batch consecutive tool calls in chat UI with shared utility (PR #11245 by @hannesrudolph) +- Validate Gemini thinkingLevel against model capabilities and handle empty streams (PR #11303 by @hannesrudolph) +- Add GLM-5 model support to Z.ai provider (PR #11440 by @app/roomote) +- Fix: Prevent double notification sound playback (PR #11283 by @hannesrudolph) +- Fix: Prevent false unsaved changes prompt with OpenAI Compatible headers (#8230 by @hannesrudolph, PR #11334 by @daniel-lxs) +- Fix: Cancel backend auto-approval timeout when auto-approve is toggled off mid-countdown (PR #11439 by @SannidhyaSah) +- Fix: Add follow_up param validation in AskFollowupQuestionTool (PR #11484 by @rossdonald) +- Fix: Prevent webview postMessage crashes and make dispose idempotent (PR #11313 by @0xMink) +- Fix: Avoid zsh process-substitution false positives in assignments (PR #11365 by @hannesrudolph) +- Fix: Harden command auto-approval against inline JS false positives (PR #11382 by @hannesrudolph) +- Fix: Make tab close best-effort in DiffViewProvider.open (PR #11363 by @0xMink) +- Fix: Canonicalize core.worktree comparison to prevent Windows path mismatch failures (PR #11346 by @0xMink) +- Fix: Make removeClineFromStack() delegation-aware to prevent orphaned parent tasks (PR #11302 by @app/roomote) +- Fix task resumption in the API module (PR #11369 by @cte) +- Make defaultTemperature required in getModelParams to prevent silent temperature overrides (PR #11218 by @app/roomote) +- Remove noisy console.warn logs from NativeToolCallParser (PR #11264 by @daniel-lxs) +- Consolidate getState calls in resolveWebviewView (PR #11320 by @0xMink) +- Clean up repo-facing mode rules (PR #11410 by @hannesrudolph) +- Implement ModelMessage storage layer with AI SDK response messages (PR #11409 by @daniel-lxs) +- Extract translation and merge resolver modes into reusable skills (PR #11215 by @app/roomote) +- Add blog section with initial posts to roocode.com (PR #11127 by @app/roomote) +- Replace Roomote Control with Linear Integration in cloud features grid (PR #11280 by @app/roomote) +- Add IPC query handlers for commands, modes, and models (PR #11279 by @cte) +- Add stdin stream mode for the CLI (PR #11476 by @cte) +- Make CLI auto-approve by default with require-approval opt-in (PR #11424 by @cte) +- Update CLI default model from Opus 4.5 to Opus 4.6 (PR #11273 by @app/roomote) +- Add linux-arm64 support for the Roo CLI (PR #11314 by @cte) +- CLI release: v0.0.51 (PR #11274 by @cte) +- CLI release: v0.0.52 (PR #11324 by @cte) +- CLI release: v0.0.53 (PR #11425 by @cte) +- CLI release: v0.0.54 (PR #11477 by @cte) + +## [3.45.0] - 2026-01-27 + +![3.45.0 Release - Smart Code Folding](/releases/3.45.0-release.png) + +- Smart Code Folding: Context condensation now intelligently preserves a lightweight map of files you worked on—function signatures, class declarations, and type definitions—so Roo can continue referencing them accurately after condensing. Files are prioritized by most recent access, with a ~50k character budget ensuring your latest work is always preserved. (Idea by @shariqriazz, PR #10942 by @hannesrudolph) + +## [3.44.2] - 2026-01-27 + +- Re-enable parallel tool calling with new_task isolation safeguards (PR #11006 by @mrubens) +- Fix worktree indexing by using relative paths in isPathInIgnoredDirectory (PR #11009 by @daniel-lxs) +- Fix local model validation error for Ollama models (PR #10893 by @roomote) +- Fix duplicate tool_call emission from Responses API providers (PR #11008 by @daniel-lxs) + +## [3.44.1] - 2026-01-27 + +- Fix LiteLLM tool ID validation errors for Bedrock proxy (PR #10990 by @daniel-lxs) +- Add temperature=0.9 and top_p=0.95 to zai-glm-4.7 model for better generation quality (PR #10945 by @sebastiand-cerebras) +- Add quality checks to marketing site deployment workflows (PR #10959 by @mp-roocode) + +## [3.44.0] - 2026-01-26 + +![3.44.0 Release - Worktrees](/releases/3.44.0-release.png) + +- Add worktree selector and creation UX (PR #10940 by @brunobergher, thanks Cline!) +- Improve subtask visibility and navigation in history and chat views (PR #10864 by @brunobergher) +- Add wildcard support for MCP alwaysAllow configuration (PR #10948 by @app/roomote) +- Fix: Prevent nested condensing from including previously-condensed content (PR #10985 by @hannesrudolph) +- Fix: VS Code LM token counting returns 0 outside requests, breaking context condensing (#10968 by @srulyt, PR #10983 by @daniel-lxs) +- Fix: Record truncation event when condensation fails but truncation succeeds (PR #10984 by @hannesrudolph) +- Replace hyphen encoding with fuzzy matching for MCP tool names (PR #10775 by @daniel-lxs) +- Remove MCP SERVERS section from system prompt for cleaner prompts (PR #10895 by @daniel-lxs) +- new_task tool creates checkpoint the same way write_to_file does (PR #10982 by @daniel-lxs) +- Update Fireworks provider with new models (#10674 by @hannesrudolph, PR #10679 by @ThanhNguyxn) +- Fix: Truncate AWS Bedrock toolUseId to 64 characters (PR #10902 by @daniel-lxs) +- Fix: Restore opaque background to settings section headers (PR #10951 by @app/roomote) +- Fix: Remove unsupported Fireworks model tool fields (PR #10937 by @app/roomote) +- Update and improve zh-TW Traditional Chinese locale and docs (PR #10953 by @PeterDaveHello) +- Chore: Remove POWER_STEERING experiment remnants (PR #10980 by @hannesrudolph) + +## [3.43.0] - 2026-01-23 + +![3.43.0 Release - Intelligent Context Condensation](/releases/3.43.0-release.png) + +- Intelligent Context Condensation v2: New context condensation system that intelligently summarizes conversation history when approaching context limits, preserving important information while reducing token usage (PR #10873 by @hannesrudolph) +- Improved context condensation with environment details, accurate token counts, and lazy evaluation for better performance (PR #10920 by @hannesrudolph) +- Move condense prompt editor to Context Management tab for better discoverability and organization (PR #10909 by @hannesrudolph) +- Update Z.AI models with new variants and pricing (#10859 by @ErdemGKSL, PR #10860 by @ErdemGKSL) +- Add pnpm install:vsix:nightly command for easier nightly build installation (PR #10912 by @hannesrudolph) +- Fix: Convert orphaned tool_results to text blocks after condensing to prevent API errors (PR #10927 by @daniel-lxs) +- Fix: Auto-migrate v1 condensing prompt and handle invalid providers on import (PR #10931 by @hannesrudolph) +- Fix: Use json-stream-stringify for pretty-printing MCP config files to prevent memory issues with large configs (#9862 by @Michaelzag, PR #9864 by @Michaelzag) +- Fix: Correct Gemini 3 pricing for Flash and Pro models (#10432 by @rossdonald, PR #10487 by @roomote) +- Fix: Skip thoughtSignature blocks during markdown export for cleaner output (#10199 by @rossdonald, PR #10932 by @rossdonald) +- Fix: Duplicate model display for OpenAI Codex provider (PR #10930 by @roomote) +- Remove diffEnabled and fuzzyMatchThreshold settings as they are no longer needed (#10648 by @hannesrudolph, PR #10298 by @hannesrudolph) +- Remove MULTI_FILE_APPLY_DIFF experiment (PR #10925 by @hannesrudolph) +- Remove POWER_STEERING experimental feature (PR #10926 by @hannesrudolph) +- Remove legacy XML tool calling code (getToolDescription) for cleaner codebase (PR #10929 by @hannesrudolph) + +## [3.42.0] - 2026-01-22 + +![3.42.0 Release - ChatGPT Usage Tracking](/releases/3.42.0-release.png) + +- Added UI to track your ChatGPT usage limits in the OpenAI Codex provider (PR #10813 by @hannesrudolph) +- Removed deprecated Claude Code provider (PR #10883 by @daniel-lxs) +- Streamlined codebase by removing legacy XML tool calling functionality (#10848 by @hannesrudolph, PR #10841 by @hannesrudolph) +- Standardize model selectors across all providers: Improved consistency of model selection UI (#10650 by @hannesrudolph, PR #10294 by @hannesrudolph) +- Enable prompt caching for Cerebras zai-glm-4.7 model (#10601 by @jahanson, PR #10670 by @app/roomote) +- Add Kimi K2 thinking model to VertexAI provider (#9268 by @diwakar-s-maurya, PR #9269 by @app/roomote) +- Warn users when too many MCP tools are enabled (PR #10772 by @app/roomote) +- Migrate context condensing prompt to customSupportPrompts (PR #10881 by @hannesrudolph) +- Unify export path logic and default to Downloads folder (PR #10882 by @hannesrudolph) +- Performance improvements for webview state synchronization (PR #10842 by @hannesrudolph) +- Fix: Handle mode selector empty state on workspace switch (#10660 by @hannesrudolph, PR #9674 by @app/roomote) +- Fix: Resolve race condition in context condensing prompt input (PR #10876 by @hannesrudolph) +- Fix: Prevent double emission of text/reasoning in OpenAI native and codex handlers (PR #10888 by @hannesrudolph) +- Fix: Prevent task abortion when resuming via IPC/bridge (PR #10892 by @cte) +- Fix: Enforce file restrictions for all editing tools (PR #10896 by @app/roomote) +- Fix: Remove custom condensing model option (PR #10901 by @hannesrudolph) +- Unify user content tags to for consistent prompt formatting (#10658 by @hannesrudolph, PR #10723 by @app/roomote) +- Clarify linked SKILL.md file handling in prompts (PR #10907 by @hannesrudolph) +- Fix: Padding on Roo Code Cloud teaser (PR #10889 by @app/roomote) + +## [3.41.3] - 2026-01-18 + +- Fix: Thinking block word-breaking to prevent horizontal scroll in the chat UI (PR #10806 by @roomote) +- Add Claude-like CLI flags and authentication fixes for the Roo Code CLI (PR #10797 by @cte) +- Improve CLI authentication by using a redirect instead of a fetch (PR #10799 by @cte) +- Fix: Roo Code Router fixes for the CLI (PR #10789 by @cte) +- Release CLI v0.0.48 with latest improvements (PR #10800 by @cte) +- Release CLI v0.0.47 (PR #10798 by @cte) +- Revert E2E tests enablement to address stability issues (PR #10794 by @cte) + +## [3.41.2] - 2026-01-16 + +- Add button to open markdown in VSCode preview for easier reading of formatted content (PR #10773 by @brunobergher) +- Fix: Reset invalid model selection when using OpenAI Codex provider (PR #10777 by @hannesrudolph) +- Fix: Add openai-codex to providers that don't require an API key (PR #10786 by @roomote) +- Fix: Detect Gemini models with space-separated names for proper thought signature injection in LiteLLM (PR #10787 by @daniel-lxs) + +## [3.41.1] - 2026-01-16 + +![3.41.1 Release - Aggregated Subtask Costs](/releases/3.41.1-release.png) + +- Feat: Aggregate subtask costs in parent task (#5376 by @hannesrudolph, PR #10757 by @taltas) +- Fix: Prevent duplicate tool_use IDs causing API 400 errors (PR #10760 by @daniel-lxs) +- Fix: Handle missing tool identity in OpenAI Native streams (PR #10719 by @hannesrudolph) +- Fix: Truncate call_id to 64 chars for OpenAI Responses API (PR #10763 by @daniel-lxs) +- Fix: Gemini thought signature validation errors (PR #10694 by @daniel-lxs) +- Fix: Filter out empty text blocks from user messages for Gemini compatibility (PR #10728 by @daniel-lxs) +- Fix: Flatten top-level anyOf/oneOf/allOf in MCP tool schemas (PR #10726 by @daniel-lxs) +- Fix: Filter Ollama models without native tool support (PR #10735 by @daniel-lxs) +- Feat: Add settings tab titles to search index (PR #10761 by @roomote) +- Feat: Clarify Slack and Linear are Cloud Team only features (PR #10748 by @roomote) + +## [3.41.0] - 2026-01-15 + +![3.41.0 Release - OpenAI - ChatGPT Plus/Pro Provider](/releases/3.41.0-release.png) + +- Add OpenAI - ChatGPT Plus/Pro Provider that gives subscription-based access to Codex models without per-token costs (PR #10736 by @hannesrudolph) +- Add gpt-5.2-codex model to openai-native provider, providing access to the latest GPT model with enhanced coding capabilities (PR #10731 by @hannesrudolph) +- Fix: Clear terminal output buffers to prevent memory leaks that could cause gray screens and performance degradation (#10666, PR #7666 by @hannesrudolph) +- Fix: Inject dummy thought signatures on ALL tool calls for Gemini models, resolving issues with Gemini tool call handling through LiteLLM (PR #10743 by @daniel-lxs) +- Enable E2E tests with 39 passing tests, improving test coverage and reliability (PR #10720 by @ArchimedesCrypto) +- Add alwaysAllow config for MCP time server tools in E2E tests (PR #10733 by @ArchimedesCrypto) + +## [3.40.1] - 2026-01-13 + +- Fix: Add allowedFunctionNames support for Gemini to prevent mode switch errors (#10711 by @hannesrudolph, PR #10708 by @hannesrudolph) + +## [3.40.0] - 2026-01-13 + +![3.40.0 Release - Settings Search](/releases/3.40.0-release.png) + +- Add settings search functionality to quickly find and navigate to specific settings (PR #10619 by @mrubens) +- Improve settings search UI with better styling and usability (PR #10633 by @brunobergher) +- Add standardized stop button for improved task cancellation visibility (PR #10639 by @brunobergher) +- Display edit_file errors in UI after consecutive failures for better debugging feedback (PR #10581 by @daniel-lxs) +- Improve error display styling and visibility in chat messages (PR #10692 by @brunobergher) +- Improve stop button visibility and streamline error handling (PR #10696 by @brunobergher) +- Fix: Omit parallel_tool_calls when not explicitly enabled to prevent API errors (#10553 by @Idlebrand, PR #10671 by @daniel-lxs) +- Fix: Encode hyphens in MCP tool names before sanitization (#10642 by @pdecat, PR #10644 by @pdecat) +- Fix: Correct Gemini 3 thought signature injection format via OpenRouter (PR #10640 by @daniel-lxs) +- Fix: Sanitize tool_use IDs to match API validation pattern (PR #10649 by @daniel-lxs) +- Fix: Use placeholder for empty tool result content to fix Gemini API validation (PR #10672 by @daniel-lxs) +- Fix: Return empty string from getReadablePath when path is empty (PR #10638 by @daniel-lxs) +- Optimize message block cloning in presentAssistantMessage for better performance (PR #10616 by @ArchimedesCrypto) + +## [3.39.3] - 2026-01-10 + +![3.39.3 Release - Roo Code Router](/releases/3.39.3-release.png) + +- Rename Roo Code Cloud Provider to Roo Code Router for clearer branding (PR #10560 by @roomote) +- Update Roo Code Router service name throughout the codebase (PR #10607 by @mrubens) +- Update router name in types for consistency (PR #10605 by @mrubens) +- Improve ExtensionHost code organization and cleanup (PR #10600 by @cte) +- Add local installation option to CLI release script for testing (PR #10597 by @cte) +- Reorganize CLI file structure for better maintainability (PR #10599 by @cte) +- Add TUI to CLI (PR #10480 by @cte) + +## [3.39.2] - 2026-01-09 + +- Fix: Ensure all tools have consistent strict mode values for Cerebras compatibility (#10334 by @brianboysen51, PR #10589 by @app/roomote) +- Fix: Remove convertToSimpleMessages to restore tool calling for OpenAI-compatible providers (PR #10575 by @daniel-lxs) +- Fix: Make edit_file matching more resilient to prevent false negatives (PR #10585 by @hannesrudolph) +- Fix: Order text parts before tool calls in assistant messages for vscode-lm (PR #10573 by @daniel-lxs) +- Fix: Ensure assistant message content is never undefined for Gemini compatibility (PR #10559 by @daniel-lxs) +- Fix: Merge approval feedback into tool result instead of pushing duplicate messages (PR #10519 by @daniel-lxs) +- Fix: Round-trip Gemini thought signatures for tool calls (PR #10590 by @hannesrudolph) +- Feature: Improve error messaging for stream termination errors from provider (PR #10548 by @daniel-lxs) +- Feature: Add debug setting to settings page for easier troubleshooting (PR #10580 by @hannesrudolph) +- Chore: Disable edit_file tool for Gemini/Vertex providers (PR #10594 by @hannesrudolph) +- Chore: Stop overriding tool allow/deny lists for Gemini (PR #10592 by @hannesrudolph) +- Chore: Change default CLI model to anthropic/claude-opus-4.5 (PR #10544 by @mrubens) +- Chore: Update Terms of Service effective January 9, 2026 (PR #10568 by @mrubens) +- Chore: Move more types to @roo-code/types for CLI support (PR #10583 by @cte) +- Chore: Add functionality to @roo-code/core for CLI support (PR #10584 by @cte) +- Chore: Add slash commands useful for CLI development (PR #10586 by @cte) + +## [3.39.1] - 2026-01-08 + +- Fix: Stabilize file paths during native tool call streaming to prevent path corruption (PR #10555 by @daniel-lxs) +- Fix: Disable Gemini thought signature persistence to prevent corrupted signature errors (PR #10554 by @daniel-lxs) +- Fix: Change minItems from 2 to 1 for Anthropic API compatibility (PR #10551 by @daniel-lxs) + +## [3.39.0] - 2026-01-08 + +![3.39.0 Release - Kangaroo go BRRR](/releases/3.39.0-release.png) + +- Implement sticky provider profile for task-level API config persistence (#8010 by @hannesrudolph, PR #10018 by @hannesrudolph) +- Add support for image file @mentions (PR #10189 by @hannesrudolph) +- Rename YOLO to BRRR (#8574 by @mojomast, PR #10507 by @roomote) +- Add debug-mode proxy routing for debugging API calls (#7042 by @SleeperSmith, PR #10467 by @hannesrudolph) +- Add Kimi K2 thinking model to Fireworks AI provider (#9201 by @kavehsfv, PR #9202 by @roomote) +- Add xhigh reasoning effort to OpenAI compatible endpoints (#10060 by @Soorma718, PR #10061 by @roomote) +- Filter @ mention file search results using .rooignore (#10169 by @jerrill-johnson-bitwerx, PR #10174 by @roomote) +- Add image support documentation to read_file native tool description (#10440 by @nabilfreeman, PR #10442 by @roomote) +- Add zai-glm-4.7 to Cerebras models (PR #10500 by @sebastiand-cerebras) +- VSCode shim and basic CLI for running Roo Code headlessly (PR #10452 by @cte) +- Add CLI installer for headless Roo Code (PR #10474 by @cte) +- Add option to use CLI for evals (PR #10456 by @cte) +- Remember last Roo model selection in web-evals and add evals skill (PR #10470 by @hannesrudolph) +- Tweak the style of follow up suggestion modes (PR #9260 by @mrubens) +- Fix: Handle PowerShell ENOENT error in os-name on Windows (#9859 by @Yang-strive, PR #9897 by @roomote) +- Fix: Make command chaining examples shell-aware for Windows compatibility (#10352 by @AlexNek, PR #10434 by @roomote) +- Fix: Preserve tool_use blocks for all tool_results in kept messages during condensation (PR #10471 by @daniel-lxs) +- Fix: Add additionalProperties: false to MCP tool schemas for OpenAI Responses API (PR #10472 by @daniel-lxs) +- Fix: Prevent duplicate tool_result blocks causing API errors (PR #10497 by @daniel-lxs) +- Fix: Add explicit deduplication for duplicate tool_result blocks (#10465 by @nabilfreeman, PR #10466 by @roomote) +- Fix: Use task stored API config as fallback for rate limit (PR #10266 by @roomote) +- Fix: Remove legacy Claude 2 series models from Bedrock provider (#9220 by @KevinZhao, PR #10501 by @roomote) +- Fix: Add missing description fields for debugProxy configuration (PR #10505 by @roomote) +- Fix: Glitchy kangaroo bounce animation on welcome screen (PR #10035 by @objectiveSee) + +## [3.38.3] - 2026-01-03 + +- Feat: Add option in Context settings to recursively load `.roo/rules` and `AGENTS.md` from subdirectories (PR #10446 by @mrubens) +- Fix: Stop frequent Claude Code sign-ins by hardening OAuth refresh token handling (PR #10410 by @hannesrudolph) +- Fix: Add `maxConcurrentFileReads` limit to native `read_file` tool schema (PR #10449 by @app/roomote) +- Fix: Add type check for `lastMessage.text` in TTS useEffect to prevent runtime errors (PR #10431 by @app/roomote) + +## [3.38.2] - 2025-12-31 + +![3.38.2 Release - Skill Alignment](/releases/3.38.2-release.png) + +- Align skills system with Agent Skills specification (PR #10409 by @hannesrudolph) +- Prevent write_to_file from creating files at truncated paths (PR #10415 by @mrubens and @daniel-lxs) +- Update Cerebras maxTokens to 16384 (PR #10387 by @sebastiand-cerebras) +- Fix rate limit wait display (PR #10389 by @hannesrudolph) +- Remove human-relay provider (PR #10388 by @hannesrudolph) +- Replace Todo Lists video with Context Management video in documentation (PR #10375 by @SannidhyaSah) + +## [3.38.1] - 2025-12-29 + +![3.38.1 Release - Bug Fixes and Stability](/releases/3.38.1-release.png) + +- Fix: Flush pending tool results before condensing context (PR #10379 by @daniel-lxs) +- Fix: Revert mergeToolResultText for OpenAI-compatible providers (PR #10381 by @hannesrudolph) +- Fix: Enforce maxConcurrentFileReads limit in read_file tool (PR #10363 by @roomote) +- Fix: Improve feedback message when read_file is used on a directory (PR #10371 by @roomote) +- Fix: Handle custom tool use similarly to MCP tools for IPC schema purposes (PR #10364 by @jr) +- Fix: Correct GitHub repository URL in marketing page (#10376 by @jishnuteegala, PR #10377 by @roomote) +- Docs: Clarify path to Security Settings in privacy policy (PR #10367 by @roomote) + +## [3.38.0] - 2025-12-27 + +![3.38.0 Release - Skills](/releases/3.38.0-release.png) + +- Add support for [Agent Skills](https://agentskills.io/), enabling reusable packages of prompts, tools, and resources to extend Roo's capabilities (PR #10335 by @mrubens) +- Add optional mode field to slash command front matter, allowing commands to automatically switch to a specific mode when triggered (PR #10344 by @app/roomote) +- Add support for npm packages and .env files to custom tools, allowing custom tools to import dependencies and access environment variables (PR #10336 by @cte) +- Remove simpleReadFileTool feature, streamlining the file reading experience (PR #10254 by @app/roomote) +- Remove OpenRouter Transforms feature (PR #10341 by @app/roomote) +- Fix mergeToolResultText handling in Roo provider (PR #10359 by @mrubens) + +## [3.37.1] - 2025-12-23 + +![3.37.1 Release - Tool Fixes and Provider Improvements](/releases/3.37.1-release.png) + +- Fix: Send native tool definitions by default for OpenAI to ensure proper tool usage (PR #10314 by @hannesrudolph) +- Fix: Preserve reasoning_details shape to prevent malformed responses when processing model output (PR #10313 by @hannesrudolph) +- Fix: Drain queued messages while waiting for ask to prevent message loss (PR #10315 by @hannesrudolph) +- Feat: Add grace retry for empty assistant messages to improve reliability (PR #10297 by @hannesrudolph) +- Feat: Enable mergeToolResultText for all OpenAI-compatible providers for better tool result handling (PR #10299 by @hannesrudolph) +- Feat: Enable mergeToolResultText for Roo Code Router (PR #10301 by @hannesrudolph) +- Feat: Strengthen native tool-use guidance in prompts for improved model behavior (PR #10311 by @hannesrudolph) +- UX: Account-centric signup flow for improved onboarding experience (PR #10306 by @brunobergher) + +## [3.37.0] - 2025-12-22 + +![3.37.0 Release - Custom Tool Calling](/releases/3.37.0-release.png) + +- Add MiniMax M2.1 and improve environment_details handling for Minimax thinking models (PR #10284 by @hannesrudolph) +- Add GLM-4.7 model with thinking mode support for Zai provider (PR #10282 by @hannesrudolph) +- Add experimental custom tool calling - define custom tools that integrate seamlessly with your AI workflow (PR #10083 by @cte) +- Deprecate XML tool protocol selection and force native tool format for new tasks (PR #10281 by @daniel-lxs) +- Fix: Emit tool_call_end events in OpenAI handler when streaming ends (#10275 by @torxeon, PR #10280 by @daniel-lxs) +- Fix: Emit tool_call_end events in BaseOpenAiCompatibleProvider (PR #10293 by @hannesrudolph) +- Fix: Disable strict mode for MCP tools to preserve optional parameters (PR #10220 by @daniel-lxs) +- Fix: Move array-specific properties into anyOf variant in normalizeToolSchema (PR #10276 by @daniel-lxs) +- Fix: Add CRLF line ending normalization to search_replace and search_and_replace tools (PR #10288 by @hannesrudolph) +- Fix: Add graceful fallback for model parsing in Chutes provider (PR #10279 by @hannesrudolph) +- Fix: Enable Requesty refresh models with credentials (PR #10273 by @daniel-lxs) +- Fix: Improve reasoning_details accumulation and serialization (PR #10285 by @hannesrudolph) +- Fix: Preserve reasoning_content in condense summary for DeepSeek-reasoner (PR #10292 by @hannesrudolph) +- Refactor Zai provider to merge environment_details into tool result instead of system message (PR #10289 by @hannesrudolph) +- Remove parallel_tool_calls parameter from litellm provider (PR #10274 by @roomote) +- Add Cloud Team page with comprehensive team management features (PR #10267 by @roomote) +- Add message log deduper utility for evals (PR #10286 by @hannesrudolph) + +## [3.36.16] - 2025-12-19 + +- Fix: Normalize tool schemas for VS Code LM API to resolve error 400 when using VS Code Language Model API providers (PR #10221 by @hannesrudolph) + +## [3.36.15] - 2025-12-19 + +![3.36.15 Release - 1M Context Window Support](/releases/3.36.15-release.png) + +- Add 1M context window beta support for Claude Sonnet 4 on Vertex AI, enabling significantly larger context for complex tasks (PR #10209 by @hannesrudolph) +- Add native tool calling support for LM Studio and Qwen-Code providers, improving compatibility with local models (PR #10208 by @hannesrudolph) +- Add native tool call defaults for OpenAI-compatible providers, expanding native function calling across more configurations (PR #10213 by @hannesrudolph) +- Enable native tool calls for Requesty provider (PR #10211 by @daniel-lxs) +- Improve API error handling and visibility with clearer error messages and better user feedback (PR #10204 by @brunobergher) +- Add downloadable error diagnostics from chat errors, making it easier to troubleshoot and report issues (PR #10188 by @brunobergher) +- Fix refresh models button not properly flushing the cache, ensuring model lists update correctly (#9682 by @tl-hbk, PR #9870 by @pdecat) +- Fix additionalProperties handling for strict mode compatibility, resolving schema validation issues with certain providers (PR #10210 by @daniel-lxs) + +## [3.36.14] - 2025-12-18 + +![3.36.14 Release - Native Tool Calling for Claude on Vertex AI](/releases/3.36.14-release.png) + +- Add native tool calling support for Claude models on Vertex AI, enabling more efficient and reliable tool interactions (PR #10197 by @hannesrudolph) +- Fix JSON Schema format value stripping for OpenAI compatibility, resolving issues with unsupported format values (PR #10198 by @daniel-lxs) +- Improve "no tools used" error handling with graceful retry mechanism for better reliability when tools fail to execute (PR #10196 by @hannesrudolph) + +## [3.36.13] - 2025-12-18 + +![3.36.13 Release - Native Tool Protocol](/releases/3.36.13-release.png) + +- Change default tool protocol from XML to native for improved reliability and performance (PR #10186 by @mrubens) +- Add native tool support for VS Code Language Model API providers (PR #10191 by @daniel-lxs) +- Lock task tool protocol for consistent task resumption, ensuring tasks resume with the same protocol they started with (PR #10192 by @daniel-lxs) +- Replace edit_file tool alias with actual edit_file tool for improved diff editing capabilities (PR #9983 by @hannesrudolph) +- Fix LiteLLM router models by merging default model info for native tool calling support (PR #10187 by @daniel-lxs) +- Add PostHog exception tracking for consecutive mistake errors to improve error monitoring (PR #10193 by @daniel-lxs) + +## [3.36.12] - 2025-12-18 + +![3.36.12 Release - Better telemetry and Bedrock fixes](/releases/3.36.12-release.png) + +- Fix: Add userAgentAppId to Bedrock embedder for code indexing (#10165 by @jackrein, PR #10166 by @roomote) +- Update OpenAI and Gemini tool preferences for improved model behavior (PR #10170 by @hannesrudolph) +- Extract error messages from JSON payloads for better PostHog error grouping (PR #10163 by @daniel-lxs) + +## [3.36.11] - 2025-12-17 + +![3.36.11 Release - Native Tool Calling Enhancements](/releases/3.36.11-release.png) + +- Add support for Claude Code Provider native tool calling, improving tool execution performance and reliability (PR #10077 by @hannesrudolph) +- Enable native tool calling by default for Z.ai models for better model compatibility (PR #10158 by @app/roomote) +- Enable native tools by default for OpenAI compatible provider to improve tool calling support (PR #10159 by @daniel-lxs) +- Fix: Normalize MCP tool schemas for Bedrock and OpenAI strict mode to ensure proper tool compatibility (PR #10148 by @daniel-lxs) +- Fix: Remove dots and colons from MCP tool names for Bedrock compatibility (PR #10152 by @daniel-lxs) +- Fix: Convert tool_result to XML text when native tools disabled for Bedrock (PR #10155 by @daniel-lxs) +- Fix: Refresh Roo models cache with session token on auth state change to resolve model list refresh issues (PR #10156 by @daniel-lxs) +- Fix: Support AWS GovCloud and China region ARNs in Bedrock provider for expanded regional support (PR #10157 by @app/roomote) + +## [3.36.10] - 2025-12-17 + +![3.36.10 Release - Gemini 3 Flash Preview](/releases/3.36.10-release.png) + +- Add support for Gemini 3 Flash Preview model in the Gemini provider (PR #10151 by @hannesrudolph) +- Implement interleaved thinking mode for DeepSeek Reasoner, enabling streaming reasoning output (PR #9969 by @hannesrudolph) +- Fix: Preserve reasoning_content during tool call sequences in DeepSeek (PR #10141 by @hannesrudolph) +- Fix: Correct token counting for context truncation display (PR #9961 by @hannesrudolph) +- Update Next.js dependency to ~15.2.8 (PR #10140 by @jr) + +## [3.36.9] - 2025-12-15 + +![3.36.9 Release - Cross-Provider Compatibility](/releases/3.36.9-release.png) + +- Fix: Normalize tool call IDs for cross-provider compatibility via OpenRouter, ensuring consistent handling across different AI providers (PR #10102 by @daniel-lxs) +- Fix: Add additionalProperties: false to nested MCP tool schemas, improving schema validation and preventing unexpected properties (PR #10109 by @daniel-lxs) +- Fix: Validate tool_result IDs in delegation resume flow, preventing errors when resuming delegated tasks (PR #10135 by @daniel-lxs) +- Feat: Add full error details to streaming failure dialog, providing more comprehensive information for debugging streaming issues (PR #10131 by @roomote) +- Feat: Improve evals UI with tool groups and duration fix, enhancing the evaluation interface organization and timing accuracy (PR #10133 by @hannesrudolph) + +## [3.36.8] - 2025-12-16 + +![3.36.8 Release - Native Tools Enabled by Default](/releases/3.36.8-release.png) + +- Implement incremental token-budgeted file reading for smarter, more efficient file content retrieval (PR #10052 by @jr) +- Enable native tools by default for multiple providers including OpenAI, Azure, Google, Vertex, and more (PR #10059 by @daniel-lxs) +- Enable native tools by default for Anthropic and add telemetry tracking for tool format usage (PR #10021 by @daniel-lxs) +- Fix: Prevent race condition from deleting wrong API messages during streaming (PR #10113 by @hannesrudolph) +- Fix: Prevent duplicate MCP tools error by deduplicating servers at source (PR #10096 by @daniel-lxs) +- Remove strict ARN validation for Bedrock custom ARN users allowing more flexibility (#10108 by @wisestmumbler, PR #10110 by @roomote) +- Add metadata to error details dialog for improved debugging (PR #10050 by @roomote) +- Add configuration to control public sharing feature (PR #10105 by @mrubens) +- Remove description from Bedrock service tiers for cleaner UI (PR #10118 by @mrubens) +- Fix: Correct link to provider pricing page on web (PR #10107 by @brunobergher) + +## [3.36.7] - 2025-12-15 + +- Improve tool configuration for OpenAI models in OpenRouter (PR #10082 by @hannesrudolph) +- Capture more detailed provider-specific error information from OpenRouter for better debugging (PR #10073 by @jr) +- Add Amazon Nova 2 Lite model to Bedrock provider (#9802 by @Smartsheet-JB-Brown, PR #9830 by @roomote) +- Add AWS Bedrock service tier support (#9874 by @Smartsheet-JB-Brown, PR #9955 by @roomote) +- Remove auto-approve toggles for to-do and retry actions to simplify the approval workflow (PR #10062 by @hannesrudolph) +- Move isToolAllowedForMode out of shared directory for better code organization (PR #10089 by @cte) +- Improve run logs and formatters in web-evals for better evaluation tracking (PR #10081 by @hannesrudolph) + +## [3.36.6] - 2025-12-12 + +![3.36.6 Release - Tool Alias Support](/releases/3.36.6-release.png) + +- Add tool alias support for model-specific tool customization, allowing users to configure how tools are presented to different AI models (PR #9989 by @daniel-lxs) +- Sanitize MCP server and tool names for API compatibility, ensuring special characters don't cause issues with API calls (PR #10054 by @daniel-lxs) +- Improve auto-approve timer visibility in follow-up suggestions for better user awareness of pending actions (PR #10048 by @brunobergher) +- Fix: Cancel auto-approval timeout when user starts typing, preventing accidental auto-approvals during user interaction (PR #9937 by @roomote) +- Add WorkspaceTaskVisibility type for organization cloud settings to support team visibility controls (PR #10020 by @roomote) +- Fix: Extract raw error message from OpenRouter metadata for clearer error reporting (PR #10039 by @daniel-lxs) +- Fix: Show tool protocol dropdown for LiteLLM provider, restoring missing configuration option (PR #10053 by @daniel-lxs) + +## [3.36.5] - 2025-12-11 + +![3.36.5 Release - GPT-5.2](/releases/3.36.5-release.png) + +- Add: GPT-5.2 model to openai-native provider (PR #10024 by @hannesrudolph) +- Add: Toggle for Enter key behavior in chat input allowing users to configure whether Enter sends or creates new line (#8555 by @lmtr0, PR #10002 by @hannesrudolph) +- Add: App version to telemetry exception captures and filter 402 errors (PR #9996 by @daniel-lxs) +- Fix: Handle empty Gemini responses and reasoning loops to prevent infinite retries (PR #10007 by @hannesrudolph) +- Fix: Add missing tool_result blocks to prevent API errors when tool results are expected (PR #10015 by @daniel-lxs) +- Fix: Filter orphaned tool_results when more results than tool_uses to prevent message validation errors (PR #10027 by @daniel-lxs) +- Fix: Add general API endpoints for Z.ai provider (#9879 by @richtong, PR #9894 by @roomote) +- Fix: Apply versioned settings on nightly builds (PR #9997 by @hannesrudolph) +- Remove: Glama provider (PR #9801 by @hannesrudolph) +- Remove: Deprecated list_code_definition_names tool (PR #10005 by @hannesrudolph) + +## [3.36.4] - 2025-12-10 + +![3.36.4 Release - Error Details Modal](/releases/3.36.4-release.png) + +- Add error details modal with on-demand display for improved error visibility when debugging issues (PR #9985 by @roomote) +- Fix: Prevent premature rawChunkTracker clearing for MCP tools, improving reliability of MCP tool streaming (PR #9993 by @daniel-lxs) +- Fix: Filter out 429 rate limit errors from API error telemetry for cleaner metrics (PR #9987 by @daniel-lxs) +- Fix: Correct TODO list display order in chat view to show items in proper sequence (PR #9991 by @roomote) + +## [3.36.3] - 2025-12-09 + +![3.36.3 Release](/releases/3.36.3-release.png) + +- Refactor: Unified context-management architecture with improved UX for better context control (PR #9795 by @hannesrudolph) +- Add new `search_replace` native tool for single-replacement operations with improved editing precision (PR #9918 by @hannesrudolph) +- Streaming tool stats and token usage throttling for better real-time feedback during generation (PR #9926 by @hannesrudolph) +- Add versioned settings support with minPluginVersion gating for Roo provider (PR #9934 by @hannesrudolph) +- Make Architect mode save plans to `/plans` directory and gitignore it (PR #9944 by @brunobergher) +- Add announcement support CTA and social icons to UI (PR #9945 by @hannesrudolph) +- Add ability to save screenshots from the browser tool (PR #9963 by @mrubens) +- Refactor: Decouple tools from system prompt for cleaner architecture (PR #9784 by @daniel-lxs) +- Update DeepSeek models to V3.2 with new pricing (PR #9962 by @hannesrudolph) +- Add minimal and medium reasoning effort levels for Gemini models (PR #9973 by @hannesrudolph) +- Update xAI models catalog with latest model options (PR #9872 by @hannesrudolph) +- Add DeepSeek V3-2 support for Baseten provider (PR #9861 by @AlexKer) +- Tweaks to Baseten model definitions for better defaults (PR #9866 by @mrubens) +- Fix: Add xhigh reasoning effort support for gpt-5.1-codex-max (#9891 by @andrewginns, PR #9900 by @andrewginns) +- Fix: Add Kimi, MiniMax, and Qwen model configurations for Bedrock (#9902 by @jbearak, PR #9905 by @app/roomote) +- Configure tool preferences for xAI models (PR #9923 by @hannesrudolph) +- Default to using native tools when supported on OpenRouter (PR #9878 by @mrubens) +- Fix: Exclude apply_diff from native tools when diffEnabled is false (#9919 by @denis-kudelin, PR #9920 by @app/roomote) +- Fix: Always show tool protocol selector for openai-compatible provider (#9965 by @bozoweed, PR #9966 by @hannesrudolph) +- Fix: Respect explicit supportsReasoningEffort array values for proper model configuration (PR #9970 by @hannesrudolph) +- Add timeout configuration to OpenAI Compatible Provider Client (PR #9898 by @dcbartlett) +- Revert default tool protocol change from xml to native for stability (PR #9956 by @mrubens) +- Remove defaultTemperature from Roo provider configuration (PR #9932 by @mrubens) +- Improve OpenAI error messages to be more useful for debugging (PR #9639 by @mrubens) +- Better error logs for parseToolCall exceptions (PR #9857 by @cte) +- Improve cloud job error logging for RCC provider errors (PR #9924 by @cte) +- Fix: Display actual API error message instead of generic text on retry (PR #9954 by @hannesrudolph) +- Add API error telemetry to OpenRouter provider for better diagnostics (PR #9953 by @daniel-lxs) +- Fix: Sanitize removed/invalid API providers to prevent infinite loop (PR #9869 by @hannesrudolph) +- Fix: Use foreground color for context-management icons (PR #9912 by @hannesrudolph) +- Fix: Suppress 'ask promise was ignored' error in handleError (PR #9914 by @daniel-lxs) +- Fix: Process finish_reason to emit tool_call_end events properly (PR #9927 by @daniel-lxs) +- Fix: Add finish_reason processing to xai.ts provider (PR #9929 by @daniel-lxs) +- Fix: Validate and fix tool_result IDs before API requests (PR #9952 by @daniel-lxs) +- Fix: Return undefined instead of 0 for disabled API timeout (PR #9960 by @hannesrudolph) +- Stop making unnecessary count_tokens requests for better performance (PR #9884 by @mrubens) +- Refactor: Consolidate ThinkingBudget components and fix disable handling (PR #9930 by @hannesrudolph) +- Forbid time estimates in architect mode for more focused planning (PR #9931 by @app/roomote) +- Web: Add product pages (PR #9865 by @brunobergher) +- Make eval runs deletable in the web UI (PR #9909 by @mrubens) +- Feat: Change defaultToolProtocol default from xml to native (later reverted) (PR #9892 by @app/roomote) + +## [3.36.2] - 2025-12-04 + +![3.36.2 Release - Dynamic API Settings](/releases/3.36.2-release.png) + +- Restrict GPT-5 tool set to apply_patch for improved compatibility (PR #9853 by @hannesrudolph) +- Add dynamic settings support for Roo models from API, allowing model-specific configurations to be fetched dynamically (PR #9852 by @hannesrudolph) +- Fix: Resolve Chutes provider model fetching issue (PR #9854 by @cte) + +## [3.36.1] - 2025-12-04 + +![3.36.1 Release - Message Management & Stability Improvements](/releases/3.36.1-release.png) + +- Add MessageManager layer for centralized history coordination, fixing message synchronization issues (PR #9842 by @hannesrudolph) +- Fix: Prevent cascading truncation loop by only truncating visible messages (PR #9844 by @hannesrudolph) +- Fix: Handle unknown/invalid native tool calls to prevent extension freeze (PR #9834 by @daniel-lxs) +- Always enable reasoning for models that require it (PR #9836 by @cte) +- ChatView: Smoother stick-to-bottom behavior during streaming (PR #8999 by @hannesrudolph) +- UX: Improved error messages and documentation links (PR #9777 by @brunobergher) +- Fix: Overly round follow-up question suggestions styling (PR #9829 by @brunobergher) +- Add symlink support for slash commands in .roo/commands folder (PR #9838 by @mrubens) +- Ignore input to the execa terminal process for safer command execution (PR #9827 by @mrubens) +- Be safer about large file reads (PR #9843 by @jr) +- Add gpt-5.1-codex-max model to OpenAI provider (PR #9848 by @hannesrudolph) +- Evals UI: Add filtering, bulk delete, tool consolidation, and run notes (PR #9837 by @hannesrudolph) +- Evals UI: Add multi-model launch and UI improvements (PR #9845 by @hannesrudolph) +- Web: New pricing page (PR #9821 by @brunobergher) + +## [3.36.0] - 2025-12-04 + +![3.36.0 Release - Rewind Kangaroo](/releases/3.36.0-release.png) + +- Fix: Restore context when rewinding after condense (#8295 by @hannesrudolph, PR #9665 by @hannesrudolph) +- Add reasoning_details support to Roo provider for enhanced model reasoning visibility (PR #9796 by @app/roomote) +- Default to native tools for all models in the Roo provider for improved performance (PR #9811 by @mrubens) +- Enable search_and_replace for Minimax models (PR #9780 by @mrubens) +- Fix: Resolve Vercel AI Gateway model fetching issues (PR #9791 by @cte) +- Fix: Apply conservative max tokens for Cerebras provider (PR #9804 by @sebastiand-cerebras) +- Fix: Remove omission detection logic to eliminate false positives (#9785 by @Michaelzag, PR #9787 by @app/roomote) +- Refactor: Remove deprecated insert_content tool (PR #9751 by @daniel-lxs) +- Chore: Hide parallel tool calls experiment and disable feature (PR #9798 by @hannesrudolph) +- Update next.js documentation site dependencies (PR #9799 by @jr) +- Fix: Correct download count display on homepage (PR #9807 by @mrubens) + +## [3.35.5] - 2025-12-03 + +- Feat: Add provider routing selection for OpenRouter embeddings (#9144 by @SannidhyaSah, PR #9693 by @SannidhyaSah) +- Default Minimax M2 to native tool calling (PR #9778 by @mrubens) +- Sanitize the native tool calls to fix a bug with Gemini (PR #9769 by @mrubens) +- UX: Updates to CloudView (PR #9776 by @roomote) + +## [3.35.4] - 2025-12-02 + +- Fix: Handle malformed native tool calls to prevent hanging (PR #9758 by @daniel-lxs) +- Fix: Remove reasoning toggles for GLM-4.5 and GLM-4.6 on z.ai provider (PR #9752 by @roomote) +- Refactor: Remove line_count parameter from write_to_file tool (PR #9667 by @hannesrudolph) + +## [3.35.3] - 2025-12-02 + +- Switch to new welcome view for improved onboarding experience (PR #9741 by @mrubens) +- Update homepage with latest changes (PR #9675 by @brunobergher) +- Improve privacy for stealth models by adding vendor confidentiality section to system prompt (PR #9742 by @mrubens) + +## [3.35.2] - 2025-12-01 + +![3.35.2 Release - Model Default Temperatures](/releases/3.35.2-release.png) + +- Allow models to contain default temperature settings for provider-specific optimal defaults (PR #9734 by @mrubens) +- Add tag-based native tool calling detection for Roo provider models (PR #9735 by @mrubens) +- Enable native tool support for all LiteLLM models by default (PR #9736 by @mrubens) +- Pass app version to provider for improved request tracking (PR #9730 by @cte) + +## [3.35.1] - 2025-12-01 + +- Fix: Flush pending tool results before task delegation (PR #9726 by @daniel-lxs) +- Improve: Better IPC error logging for easier debugging (PR #9727 by @cte) + +## [3.35.0] - 2025-12-01 + +![3.35.0 Release - Subtasks & Native Tools](/releases/3.35.0-release.png) + +- Metadata-driven subtasks with automatic parent resume and single-open safety for improved task orchestration (#8081 by @hannesrudolph, PR #9090 by @hannesrudolph) +- Native tool calling support expanded across many providers: Bedrock (PR #9698 by @mrubens), Cerebras (PR #9692 by @mrubens), Chutes with auto-detection from API (PR #9715 by @daniel-lxs), DeepInfra (PR #9691 by @mrubens), DeepSeek and Doubao (PR #9671 by @daniel-lxs), Groq (PR #9673 by @daniel-lxs), LiteLLM (PR #9719 by @daniel-lxs), Ollama (PR #9696 by @mrubens), OpenAI-compatible providers (PR #9676 by @daniel-lxs), Requesty (PR #9672 by @daniel-lxs), Unbound (PR #9699 by @mrubens), Vercel AI Gateway (PR #9697 by @mrubens), Vertex Gemini (PR #9678 by @daniel-lxs), and xAI with new Grok 4 Fast and Grok 4.1 Fast models (PR #9690 by @mrubens) +- Fix: Preserve tool_use blocks in summary for parallel tool calls (#9700 by @SilentFlower, PR #9714 by @SilentFlower) +- Default Grok Code Fast to native tools for better performance (PR #9717 by @mrubens) +- UX improvements to the Roo Code Router-centric onboarding flow (PR #9709 by @brunobergher) +- UX toolbar cleanup and settings consolidation for a cleaner interface (PR #9710 by @brunobergher) +- Add model-specific tool customization via `excludedTools` and `includedTools` configuration (PR #9641 by @daniel-lxs) +- Add new `apply_patch` native tool for more efficient file editing operations (PR #9663 by @hannesrudolph) +- Add new `search_and_replace` tool for batch text replacements across files (PR #9549 by @hannesrudolph) +- Add debug buttons to view API and UI history for troubleshooting (PR #9684 by @hannesrudolph) +- Include tool format in environment details for better context awareness (PR #9661 by @mrubens) +- Fix: Display install count in millions instead of thousands (PR #9677 by @app/roomote) +- Web-evals improvements: add task log viewing, export failed logs, and new run options (PR #9637 by @hannesrudolph) +- Web-evals updates: add kill run functionality (PR #9681 by @hannesrudolph) +- Fix: Prevent navigation buttons from wrapping on smaller screens (PR #9721 by @app/roomote) + +## [3.34.8] - 2025-11-27 + +![3.34.8 Release - Race Condition Fix](/releases/3.34.8-release.png) + +- Fix: Race condition in new_task tool for native protocol (PR #9655 by @daniel-lxs) + +## [3.34.7] - 2025-11-27 + +![3.34.7 Release - More Native Tool Integrations](/releases/3.34.7-release.png) + +- Support native tools in the Anthropic provider for improved tool calling (PR #9644 by @mrubens) +- Enable native tool calling for z.ai models (PR #9645 by @mrubens) +- Enable native tool calling for Moonshot models (PR #9646 by @mrubens) +- Fix: OpenRouter tool calls handling improvements (PR #9642 by @mrubens) +- Fix: OpenRouter GPT-5 strict schema validation for read_file tool (PR #9633 by @daniel-lxs) +- Fix: Create parent directories early in write_to_file to prevent ENOENT errors (#9634 by @ivanenev, PR #9640 by @daniel-lxs) +- Fix: Disable native tools and temperature support for claude-code provider (PR #9643 by @hannesrudolph) +- Add 'taking you to cloud' screen after provider welcome for improved onboarding (PR #9652 by @mrubens) + +## [3.34.6] - 2025-11-26 + +![3.34.6 Release - Bedrock Embeddings](/releases/3.34.6-release.png) + +- Add support for AWS Bedrock embeddings in code indexing (#8658 by @kyle-hobbs, PR #9475 by @ggoranov-smar) +- Add native tool calling support for Mistral provider (PR #9625 by @hannesrudolph) +- Wire MULTIPLE_NATIVE_TOOL_CALLS experiment to OpenAI parallel_tool_calls for parallel tool execution (PR #9621 by @hannesrudolph) +- Add fine grained tool streaming for OpenRouter Anthropic (PR #9629 by @mrubens) +- Allow global inference selection for Bedrock when cross-region is enabled (PR #9616 by @roomote) +- Fix: Filter non-Anthropic content blocks before sending to Vertex API (#9583 by @cardil, PR #9618 by @hannesrudolph) +- Fix: Restore content undefined check in WriteToFileTool.handlePartial() (#9611 by @Lissanro, PR #9614 by @daniel-lxs) +- Fix: Prevent model cache from persisting empty API responses (#9597 by @zx2021210538, PR #9623 by @daniel-lxs) +- Fix: Exclude access_mcp_resource tool when MCP has no resources (PR #9615 by @daniel-lxs) +- Fix: Update default settings for inline terminal and codebase indexing (PR #9622 by @roomote) +- Fix: Convert line_ranges strings to lineRanges objects in native tool calls (PR #9627 by @daniel-lxs) +- Fix: Defer new_task tool_result until subtask completes for native protocol (PR #9628 by @daniel-lxs) + +## [3.34.5] - 2025-11-25 + +![3.34.5 Release - Experimental Parallel Tool Calling](/releases/3.34.5-release.png) + +- Experimental feature to enable multiple native tool calls per turn (PR #9273 by @daniel-lxs) +- Add Bedrock Opus 4.5 to global inference model list (PR #9595 by @roomote) +- Fix: Update API handler when toolProtocol changes (PR #9599 by @mrubens) +- Set native tools as default for minimax-m2 and claude-haiku-4.5 (PR #9586 by @daniel-lxs) +- Make single file read only apply to XML tools (PR #9600 by @mrubens) +- Enhance web-evals dashboard with dynamic tool columns and UX improvements (PR #9592 by @hannesrudolph) +- Revert "Add support for Roo Code Cloud as an embeddings provider" while we fix some issues (PR #9602 by @mrubens) + +## [3.34.4] - 2025-11-25 + +![3.34.4 Release - BFL Image Generation](/releases/3.34.4-release.png) + +- Add new Black Forest Labs image generation models, free on Roo Code Cloud and also available on OpenRouter (PR #9587 and #9589 by @mrubens) +- Fix: Preserve dynamic MCP tool names in native mode API history to prevent tool name mismatches (PR #9559 by @daniel-lxs) +- Fix: Preserve tool_use blocks in summary message during condensing with native tools to maintain conversation context (PR #9582 by @daniel-lxs) + +## [3.34.3] - 2025-11-25 + +![3.34.3 Release - Streaming and Opus 4.5](/releases/3.34.3-release.png) + +- Implement streaming for native tool calls, providing real-time feedback during tool execution (PR #9542 by @daniel-lxs) +- Add Claude Opus 4.5 model to Claude Code provider (PR #9560 by @mrubens) +- Add Claude Opus 4.5 model to Bedrock provider (#9571 by @pisicode, PR #9572 by @roomote) +- Enable caching for Opus 4.5 model to improve performance (#9567 by @iainRedro, PR #9568 by @roomote) +- Add support for Roo Code Cloud as an embeddings provider (PR #9543 by @mrubens) +- Fix ask_followup_question streaming issue and add missing tool cases (PR #9561 by @daniel-lxs) +- Add contact links to About Roo Code settings page (PR #9570 by @roomote) +- Switch from asdf to mise-en-place in bare-metal evals setup script (PR #9548 by @cte) + +## [3.34.2] - 2025-11-24 + +![3.34.2 Release - Opus Conductor](/releases/3.34.2-release.png) + +- Add support for Claude Opus 4.5 in Anthropic and Vertex providers (PR #9541 by @daniel-lxs) +- Add support for Claude Opus 4.5 in OpenRouter with prompt caching and reasoning budget (PR #9540 by @daniel-lxs) +- Add Roo Code Cloud as an image generation provider (PR #9528 by @mrubens) +- Fix: Gracefully skip unsupported content blocks in Gemini transformer (PR #9537 by @daniel-lxs) +- Fix: Flush LiteLLM cache when credentials change on refresh (PR #9536 by @daniel-lxs) +- Fix: Ensure XML parser state matches tool protocol on config update (PR #9535 by @daniel-lxs) +- Update Cerebras models (PR #9527 by @sebastiand-cerebras) +- Fix: Support reasoning_details format for Gemini 3 models (PR #9506 by @daniel-lxs) + +## [3.34.1] - 2025-11-23 + +- Show the prompt for image generation in the UI (PR #9505 by @mrubens) +- Fix double todo list display issue (PR #9517 by @mrubens) +- Add tracking for cloud synced messages (PR #9518 by @mrubens) +- Enable the Roo Code Router in evals (PR #9492 by @cte) + +## [3.34.0] - 2025-11-21 + +![3.34.0 Release - Browser Use 2.0](/releases/3.34.0-release.png) + +- Add Browser Use 2.0 with enhanced browser interaction capabilities (PR #8941 by @hannesrudolph) +- Add support for Baseten as a new AI provider (PR #9461 by @AlexKer) +- Improve base OpenAI compatible provider with better error handling and configuration (PR #9462 by @mrubens) +- Add provider-oriented welcome screen to improve onboarding experience (PR #9484 by @mrubens) +- Pin Roo provider to the top of the provider list for better discoverability (PR #9485 by @mrubens) +- Enhance native tool descriptions with examples and clarifications for better AI understanding (PR #9486 by @daniel-lxs) +- Fix: Make cancel button immediately responsive during streaming (#9435 by @jwadow, PR #9448 by @daniel-lxs) +- Fix: Resolve apply_diff performance regression from earlier changes (PR #9474 by @daniel-lxs) +- Fix: Implement model cache refresh to prevent stale disk cache issues (PR #9478 by @daniel-lxs) +- Fix: Copy model-level capabilities to OpenRouter endpoint models correctly (PR #9483 by @daniel-lxs) +- Fix: Add fallback to yield tool calls regardless of finish_reason (PR #9476 by @daniel-lxs) + +## [3.33.3] - 2025-11-20 + +![3.33.3 Release - Gemini 3 Pro Image Preview](/releases/3.33.3-release.png) + +- Add Google Gemini 3 Pro Image Preview to image generation models (PR #9440 by @app/roomote) +- Add support for Minimax as Anthropic-compatible provider (PR #9455 by @daniel-lxs) +- Store reasoning in conversation history for all providers (PR #9451 by @daniel-lxs) +- Fix: Improve preserveReasoning flag to control API reasoning inclusion (PR #9453 by @daniel-lxs) +- Fix: Prevent OpenAI Native parallel tool calls for native tool calling (PR #9433 by @hannesrudolph) +- Fix: Improve search and replace symbol parsing (PR #9456 by @daniel-lxs) +- Fix: Send tool_result blocks for skipped tools in native protocol (PR #9457 by @daniel-lxs) +- Fix: Improve markdown formatting and add reasoning support (PR #9458 by @daniel-lxs) +- Fix: Prevent duplicate environment_details when resuming cancelled tasks (PR #9442 by @daniel-lxs) +- Improve read_file tool description with examples (PR #9422 by @daniel-lxs) +- Update glob dependency to ^11.1.0 (PR #9449 by @jr) +- Update tar-fs to 3.1.1 via pnpm override (PR #9450 by @app/roomote) + +## [3.33.2] - 2025-11-19 + +- Enable native tool calling for Gemini provider (PR #9343 by @hannesrudolph) +- Add RCC credit balance display (PR #9386 by @jr) +- Fix: Preserve user images in native tool call results (PR #9401 by @daniel-lxs) +- Perf: Reduce excessive getModel() calls and implement disk cache fallback (PR #9410 by @daniel-lxs) +- Show zero price for free models (PR #9419 by @mrubens) + +## [3.33.1] - 2025-11-18 + +![3.33.1 Release - Native Tool Protocol Fixes](/releases/3.33.1-release.png) + +- Add native tool calling support to OpenAI-compatible (PR #9369 by @mrubens) +- Fix: Resolve native tool protocol race condition causing 400 errors (PR #9363 by @daniel-lxs) +- Fix: Update tools to return structured JSON for native protocol (PR #9373 by @daniel-lxs) +- Fix: Include nativeArgs in tool repetition detection (PR #9377 by @daniel-lxs) +- Fix: Ensure no XML parsing when protocol is native (PR #9371 by @daniel-lxs) +- Fix: Gemini maxOutputTokens and reasoning config (PR #9375 by @hannesrudolph) +- Fix: Gemini thought signature validation and token counting errors (PR #9380 by @hannesrudolph) +- Fix: Exclude XML tool examples from MODES section when native protocol enabled (PR #9367 by @daniel-lxs) +- Retry eval tasks if API instability detected (PR #9365 by @cte) +- Add toolProtocol property to PostHog tool usage telemetry (PR #9374 by @app/roomote) + +## [3.33.0] - 2025-11-18 + +![3.33.0 Release - Twin Kangaroos and the Gemini Constellation](/releases/3.33.0-release.png) + +- Add Gemini 3 Pro Preview model (PR #9357 by @hannesrudolph) +- Improve Google Gemini defaults with better temperature and cost reporting (PR #9327 by @hannesrudolph) +- Enable native tool calling for openai-native provider (PR #9348 by @hannesrudolph) +- Add git status information to environment details (PR #9310 by @daniel-lxs) +- Add tool protocol selector to advanced settings (PR #9324 by @daniel-lxs) +- Implement dynamic tool protocol resolution with proper precedence hierarchy (PR #9286 by @daniel-lxs) +- Move Import/Export functionality to Modes view toolbar and cleanup Mode Edit view (PR #9077 by @hannesrudolph) +- Update cloud agent CTA to point to setup page (PR #9338 by @app/roomote) +- Fix: Prevent duplicate tool_result blocks in native tool protocol (PR #9248 by @daniel-lxs) +- Fix: Format tool responses properly for native protocol (PR #9270 by @daniel-lxs) +- Fix: Centralize toolProtocol configuration checks (PR #9279 by @daniel-lxs) +- Fix: Preserve tool blocks for native protocol in conversation history (PR #9319 by @daniel-lxs) +- Fix: Prevent infinite loop when task_done succeeds (PR #9325 by @daniel-lxs) +- Fix: Sync parser state with profile/model changes (PR #9355 by @daniel-lxs) +- Fix: Pass tool protocol parameter to lineCountTruncationError (PR #9358 by @daniel-lxs) +- Use VSCode theme color for outline button borders (PR #9336 by @app/roomote) +- Replace broken badgen.net badges with shields.io (PR #9318 by @app/roomote) +- Add max git status files setting to evals (PR #9322 by @mrubens) +- Roo Code Router pricing page and changes elsewhere (PR #9195 by @brunobergher) + +## [3.32.1] - 2025-11-14 + +![3.32.1 Release - Bug Fixes](/releases/3.32.1-release.png) + +- Fix: Add abort controller for request cancellation in OpenAI native protocol (PR #9276 by @daniel-lxs) +- Fix: Resolve duplicate tool blocks causing 'tool has already been used' error in native protocol mode (PR #9275 by @daniel-lxs) +- Fix: Prevent duplicate tool_result blocks in native protocol mode for read_file (PR #9272 by @daniel-lxs) +- Fix: Correct OpenAI Native handling of encrypted reasoning blocks to prevent errors during condensing (PR #9263 by @hannesrudolph) +- Fix: Disable XML parser for native tool protocol to prevent parsing conflicts (PR #9277 by @daniel-lxs) + +## [3.32.0] - 2025-11-14 + +![3.32.0 Release - GPT-5.1 models and OpenAI prompt caching](/releases/3.32.0-release.png) + +- Feature: Add GPT-5.1 models to OpenAI provider (PR #9252 by @hannesrudolph) +- Feature: Support for OpenAI Responses 24 hour prompt caching (PR #9259 by @hannesrudolph) +- Fix: Repair the share button in the UI (PR #9253 by @hannesrudolph) +- Docs: Include PR numbers in the release guide to improve traceability (PR #9236 by @hannesrudolph) + +## [3.31.3] - 2025-11-13 + +![3.31.3 Release - Kangaroo Decrypting a Message](/releases/3.31.3-release.png) + +- Fix: OpenAI Native encrypted_content handling and remove gpt-5-chat-latest verbosity flag (#9225 by @politsin, PR by @hannesrudolph) +- Fix: Roo Code Router Anthropic input token normalization to avoid double-counting (thanks @hannesrudolph!) +- Refactor: Rename sliding-window to context-management and truncateConversationIfNeeded to manageContext (thanks @hannesrudolph!) + +## [3.31.2] - 2025-11-12 + +- Fix: Apply updated API profile settings when provider/model unchanged (#9208 by @hannesrudolph, PR by @hannesrudolph) +- Migrate conversation continuity to plugin-side encrypted reasoning items using Responses API for improved reliability (thanks @hannesrudolph!) +- Fix: Include mcpServers in getState() for auto-approval (#9190 by @bozoweed, PR by @daniel-lxs) +- Batch settings updates from the webview to the extension host for improved performance (thanks @cte!) +- Fix: Replace rate-limited badges with badgen.net to improve README reliability (thanks @daniel-lxs!) + +## [3.31.1] - 2025-11-11 + +![3.31.1 Release - Kangaroo Stuck in the Clouds](/releases/3.31.1-release.png) + +- Fix: Prevent command_output ask from blocking in cloud/headless environments (thanks @daniel-lxs!) +- Add IPC command for sending messages to the current task (thanks @mrubens!) +- Fix: Model switch re-applies selected profile, ensuring task configuration stays in sync (#9179 by @hannesrudolph, PR by @hannesrudolph) +- Move auto-approval logic from `ChatView` to `Task` for better architecture (thanks @cte!) +- Add custom Button component with variant system (thanks @brunobergher!) + +## [3.31.0] - 2025-11-07 + +![3.31.0 Release - Todo List and Task Header Improvements](/releases/3.31.0-release.png) + +- Improvements to to-do lists and task headers (thanks @brunobergher!) +- Fix: Prevent crash when streaming chunks have null choices array (thanks @daniel-lxs!) +- Fix: Prevent context condensing on settings save when provider/model unchanged (#4430 by @hannesrudolph, PR by @daniel-lxs) +- Fix: Respect custom OpenRouter URL for all API operations (#8947 by @sstraus, PR by @roomote) +- Add comprehensive error logging to Roo Cloud provider (thanks @daniel-lxs!) +- UX: Less caffeinated kangaroo (thanks @brunobergher!) + +## [3.30.3] - 2025-11-06 + +![3.30.3 Release - Moonshot Brain](/releases/3.30.3-release.png) + +- Feat: Add kimi-k2-thinking model to Moonshot provider (thanks @daniel-lxs!) +- Fix: Auto-retry on empty assistant response to prevent task failures (#9076 by @Akillatech, PR by @daniel-lxs) +- Fix: Use system role for OpenAI Compatible provider when streaming is disabled (#8215 by @whitfin, PR by @roomote) +- Fix: Prevent notification sound on attempt_completion with queued messages (#8537 by @hannesrudolph, PR by @roomote) +- Feat: Auto-switch to imported mode with architect fallback for better mode detection (#8239 by @hannesrudolph, PR by @daniel-lxs) +- Feat: Add MiniMax-M2-Stable model and enable prompt caching (#9070 by @nokaka, PR by @roomote) +- Feat: Improve diff appearance in main chat view (thanks @hannesrudolph!) +- UX: Home screen visuals (thanks @brunobergher!) +- Docs: Clarify that setting 0 disables Error & Repetition Limit (thanks @roomote!) +- Chore: Update dependency @changesets/cli to v2.29.7 (thanks @renovate!) + +## [3.30.2] - 2025-11-05 + +![3.30.2 Release - Eliminating UI Flicker](/releases/3.30.2-release.png) + +- Fix: eliminate UI flicker during task cancellation (thanks @daniel-lxs!) +- Add Global Inference support for Bedrock models (#8750 by @ronyblum, PR by @hannesrudolph) +- Add Qwen3 embedding models (0.6B and 4B) to OpenRouter support (#9058 by @dmarkey, PR by @app/roomote) +- Fix: resolve incorrect commit location when GIT_DIR set in Dev Containers (#4567 by @nonsleepr, PR by @heyseth) +- Fix: keep pinned models fixed at top of scrollable list (#8812 by @XiaoYingYo, PR by @app/roomote) +- Fix: update Opus 4.1 max tokens from 8K to 32K (#9045 by @kaveh-deriv, PR by @app/roomote) +- Set Claude Sonnet 4.5 as default for key providers (thanks @hannesrudolph!) +- Fix: dynamic provider model validation to prevent cross-contamination (#9047 by @NotADev137, PR by @daniel-lxs) +- Fix: Bedrock user agent to report full SDK details (#9031 by @ajjuaire, PR by @ajjuaire) +- Add file path tooltips with centralized PathTooltip component (#8278 by @da2ce7, PR by @daniel-lxs) +- Add conditional test running to pre-push hook (thanks @daniel-lxs!) +- Update Cerebras integration (thanks @sebastiand-cerebras!) + +## [3.30.1] - 2025-11-04 + +- Fix: Correct OpenRouter Mistral model embedding dimension from 3072 to 1536 (thanks @daniel-lxs!) +- Revert: Previous UI flicker fix that caused issues with task resumption (thanks @mrubens!) + +## [3.30.0] - 2025-11-03 + +![3.30.0 Release - PR Fixer](/releases/3.30.0-release.png) + +- Feat: Add OpenRouter embedding provider support (#8972 by @dmarkey, PR by @dmarkey) +- Feat: Add GLM-4.6 model to Fireworks provider (#8752 by @mmealman, PR by @app/roomote) +- Feat: Add MiniMax M2 model to Fireworks provider (#8961 by @dmarkey, PR by @app/roomote) +- Feat: Add preserveReasoning flag to include reasoning in API history (thanks @daniel-lxs!) +- Fix: Prevent message loss during queue drain race condition (#8536 by @hannesrudolph, PR by @daniel-lxs) +- Fix: Capture the reasoning content in base-openai-compatible for GLM 4.6 (thanks @mrubens!) +- Fix: Create new Requesty profile during OAuth (thanks @Thibault00!) +- Fix: Prevent UI flicker and enable resumption after task cancellation (thanks @daniel-lxs!) +- Fix: Cleanup terminal settings tab and change default terminal to inline (thanks @hannesrudolph!) + +## [3.29.5] - 2025-11-01 + +- Fix: Resolve Qdrant codebase_search error by adding keyword index for type field (#8963 by @rossdonald, PR by @app/roomote) +- Fix cost and token tracking between provider styles to ensure accurate usage metrics (thanks @mrubens!) + +## [3.29.4] - 2025-10-30 + +- Feat: Add Minimax Provider (thanks @Maosghoul!) +- Fix: prevent infinite loop when canceling during auto-retry (#8901 by @mini2s, PR by @app/roomote) +- Fix: Enhanced codebase index recovery and reuse ('Start Indexing' button now reuses existing Qdrant index) (#8129 by @jaroslaw-weber, PR by @heyseth) +- Fix: make code index initialization non-blocking at activation (#8777 by @cjlawson02, PR by @daniel-lxs) +- Fix: remove search_and_replace tool from codebase (#8891 by @hannesrudolph, PR by @app/roomote) +- Fix: custom modes under custom path not showing (#8122 by @hannesrudolph, PR by @elianiva) +- Fix: prevent MCP server restart when toggling tool permissions (#8231 by @hannesrudolph, PR by @heyseth) +- Fix: truncate type definition to match max read line (#8149 by @chenxluo, PR by @elianiva) +- Fix: auto-sync enableReasoningEffort with reasoning dropdown selection (thanks @daniel-lxs!) +- Fix: Gate auth-driven Roo model refresh to active provider only (thanks @daniel-lxs!) +- Prevent a noisy cloud agent exception (thanks @cte!) +- Feat: improve @ file search for large projects (#5721 by @Naituw, PR by @daniel-lxs) +- Feat: add zai-glm-4.6 model to Cerebras and set gpt-oss-120b as default (thanks @kevint-cerebras!) +- Feat: rename MCP Errors tab to Logs for mixed-level messages (#8893 by @hannesrudolph, PR by @app/roomote) +- docs(vscode-lm): clarify VS Code LM API integration warning (thanks @hannesrudolph!) + +## [3.29.3] - 2025-10-28 + +- Update Gemini models with latest 09-2025 versions including Gemini 2.5 Pro and Flash (#8485 by @cleacos, PR by @roomote) +- Add reasoning support for Z.ai GLM binary thinking mode (#8465 by @BeWater799, PR by @daniel-lxs) +- Enable reasoning in Roo provider (thanks @mrubens!) +- Add settings to configure time and cost display in system prompt (#8450 by @jaxnb, PR by @roomote) +- Fix: Use max_output_tokens when available in LiteLLM fetcher (#8454 by @fabb, PR by @roomote) +- Fix: Process queued messages after context condensing completes (#8477 by @JosXa, PR by @roomote) +- Fix: Use monotonic clock for rate limiting to prevent timing issues (#7770 by @intermarkec, PR by @chrarnoldus) +- Fix: Resolve checkpoint menu popover overflow (thanks @daniel-lxs!) +- Fix: LiteLLM test failures after merge (thanks @daniel-lxs!) +- Improve UX: Focus textbox and add newlines after adding to context (thanks @mrubens!) + +## [3.29.2] - 2025-10-27 + +- Add support for LongCat-Flash-Thinking-FP8 models in Chutes AI provider (#8425 by @leakless21, PR by @roomote) +- Fix: Remove specific Claude model version from settings descriptions to avoid outdated references (#8435 by @rwydaegh, PR by @roomote) +- Fix: Correct caching logic in Roo provider to improve performance (thanks @mrubens!) +- Fix: Ensure free models don't display pricing information in the UI (thanks @mrubens!) + +## [3.29.1] - 2025-10-26 + +![3.29.1 Release - Window Cleaning](/releases/3.29.1-release.png) + +- Fix: Clean up max output token calculations to prevent context window overruns (#8821 by @enerage, PR by @roomote) +- Fix: Change Add to Context keybinding to avoid Redo conflict (#8652 by @swythan, PR by @roomote) +- Fix provider model loading race conditions (thanks @mrubens!) + +## [3.29.0] - 2025-10-24 + +![3.29.0 Release - Intelligent File Reading](/releases/3.29.0-release.png) + +- Add token-budget based file reading with intelligent preview to avoid context overruns (thanks @daniel-lxs!) +- Enable browser-use tool for all image-capable models (#8116 by @hannesrudolph, PR by @app/roomote!) +- Add dynamic model loading for Roo Code Router (thanks @app/roomote!) +- Fix: Respect nested .gitignore files in search_files (#7921 by @hannesrudolph, PR by @daniel-lxs) +- Fix: Preserve trailing newlines in stripLineNumbers for apply_diff (#8020 by @liyi3c, PR by @app/roomote) +- Fix: Exclude max tokens field for models that don't support it in export (#7944 by @hannesrudolph, PR by @elianiva) +- Retry API requests on stream failures instead of aborting task (thanks @daniel-lxs!) +- Improve auto-approve button responsiveness (thanks @daniel-lxs!) +- Add checkpoint initialization timeout settings and fix checkpoint timeout warnings (#7843 by @NaccOll, PR by @NaccOll) +- Always show checkpoint restore options regardless of change detection (thanks @daniel-lxs!) +- Improve checkpoint menu translations (thanks @daniel-lxs!) +- Add GLM-4.6-turbo model to chutes ai provider (thanks @mohammad154!) +- Add Claude Haiku 4.5 to prompt caching models (thanks @hannesrudolph!) +- Expand Z.ai model coverage with GLM-4.5-X, AirX, Flash (thanks @hannesrudolph!) +- Update Mistral Medium model name (#8362 by @ThomsenDrake, PR by @ThomsenDrake) +- Remove GPT-5 instructions/reasoning_summary from UI message metadata to prevent ui_messages.json bloat (thanks @hannesrudolph!) +- Normalize docs-extractor audience tags; remove admin/stakeholder; strip tool invocations (thanks @hannesrudolph!) +- Update X/Twitter username from roo_code to roocode (thanks @app/roomote!) +- Update Configuring Profiles video link (thanks @app/roomote!) +- Fix link text for Roomote Control in README (thanks @laz-001!) +- Remove verbose error for cloud agents (thanks @cte!) +- Try 5s status mutation timeout (thanks @cte!) + +## [3.28.18] - 2025-10-17 + +- Fix: Remove request content from UI messages to improve performance and reduce clutter (#5601 by @MuriloFP, #8594 by @multivac2x, #8690 by @hannesrudolph, PR by @mrubens) +- Fix: Prevent file editing issues when git diff views are open (thanks @hassoncs!) +- Fix: Add userAgent to Bedrock client for version tracking (#8660 by @ajjuaire, PR by @app/roomote) +- Feat: Z AI now uses only two coding endpoints for better performance (#8687 by @hannesrudolph) +- Feat: Update image generation model selection for improved quality (thanks @chrarnoldus!) + +## [3.28.17] - 2025-10-15 + +- Add support for Claude Haiku 4.5 model (thanks @daniel-lxs!) +- Fix: Update zh-TW run command title translation (thanks @PeterDaveHello!) + +## [3.28.16] - 2025-10-09 + +![3.28.16 Release - Expanded Context Window](/releases/3.28.16-release.png) + +- feat: Add Claude Sonnet 4.5 1M context window support for Claude Code (thanks @ColbySerpa!) +- feat: Identify cloud tasks in the extension bridge (thanks @cte!) +- fix: Add the parent task ID in telemetry (thanks @mrubens!) + +## [3.28.15] - 2025-10-03 + +![3.28.15 Release - Kangaroo Sliding Down a Chute](/releases/3.28.15-release.png) + +- Add new DeepSeek and GLM models with detailed descriptions to the Chutes provider (thanks @mohammad154!) +- Fix: properly reset cost limit tracking when user clicks "Reset and Continue" (#6889 by @alecoot, PR by app/roomote) +- Fix: improve save button activation in prompts settings (#5780 by @beccare, PR by app/roomote) +- Fix: overeager 'there are unsaved changes' dialog in settings (thanks @brunobergher!) +- Fix: show send button when only images are selected in chat textarea (thanks app/roomote!) +- Fix: Claude Sonnet 4.5 compatibility improvements (thanks @mrubens!) +- Add UsageStats schema and type for better analytics tracking (thanks app/roomote!) +- Include reasoning messages in cloud tasks (thanks @mrubens!) +- Security: update dependency vite to v6.3.6 (thanks app/renovate!) +- Deprecate free grok 4 fast model (thanks @mrubens!) +- Remove unsupported Gemini 2.5 Flash Image Preview free model (thanks @SannidhyaSah!) +- Add structured data to the homepage for better SEO (thanks @mrubens!) +- Update dependency glob to v11.0.3 (thanks app/renovate!) + +## [3.28.14] - 2025-09-30 + +![3.28.14 Release - GLM-4.6 Model Support](/releases/3.28.14-release.png) + +- Add support for GLM-4.6 model for z.ai provider (#8406 by @dmarkey, PR by @roomote) + +## [3.28.13] - 2025-09-29 + +- Fix: Remove topP parameter from Bedrock inference config (#8377 by @ronyblum, PR by @daniel-lxs) +- Fix: Correct Vertex AI Sonnet 4.5 model configuration (#8387 by @nickcatal, PR by @mrubens!) + +## [3.28.12] - 2025-09-29 + +- Fix: Correct Anthropic Sonnet 4.5 model ID and add Bedrock 1M context checkbox (thanks @daniel-lxs!) + +## [3.28.11] - 2025-09-29 + +- Fix: Correct Amazon Bedrock Claude Sonnet 4.5 model identifier (#8371 by @sunhyung, PR by @app/roomote) +- Fix: Correct Claude Sonnet 4.5 model ID format (thanks @daniel-lxs!) + +## [3.28.10] - 2025-09-29 + +![3.28.10 Release - Kangaroo Writing Sonnet 4.5](/releases/3.28.10-release.png) + +- Feat: Add Sonnet 4.5 support (thanks @daniel-lxs!) +- Fix: Resolve max_completion_tokens issue for GPT-5 models in LiteLLM provider (#6979 by @lx1054331851, PR by @roomote) +- Fix: Make chat icons properly sized with shrink-0 class (thanks @mrubens!) +- Enhancement: Track telemetry settings changes for better analytics (thanks @mrubens!) +- Web: Add testimonials section to website (thanks @brunobergher!) +- CI: Refresh contrib.rocks cache workflow for contributor badges (thanks @hannesrudolph!) + +## [3.28.9] - 2025-09-26 + +![3.28.9 Release - Supernova Upgrade](/releases/3.28.9-release.png) + +- The free Supernova model now has a 1M token context window (thanks @mrubens!) +- Experiment to show the Roo provider on the welcome screen (thanks @mrubens!) +- Web: Website improvements to https://roocode.com/ (thanks @brunobergher!) +- Fix: Remove tags from prompts for cleaner output and fewer tokens (#8318 by @hannesrudolph, PR by @app/roomote) +- Correct tool use suggestion to improve model adherence to suggestion (thanks @hannesrudolph!) +- feat: log out from cloud when resetting extension state (thanks @app/roomote!) +- feat: Add telemetry tracking to DismissibleUpsell component (thanks @app/roomote!) +- refactor: remove pr-reviewer mode (thanks @daniel-lxs!) +- Removing user hint when refreshing models (thanks @requesty-JohnCosta27!) + +## [3.28.8] - 2025-09-25 + +![3.28.8 Release - Bug fixes and improvements](/releases/3.28.8-release.png) + +- Fix: Resolve frequent "No tool used" errors by clarifying tool-use rules (thanks @hannesrudolph!) +- Fix: Include initial ask in condense summarization (thanks @hannesrudolph!) +- Add support for more free models in the Roo provider (thanks @mrubens!) +- Show cloud switcher and option to add a team when logged in (thanks @mrubens!) +- Add Opengraph image for web (thanks @brunobergher!) + +## [3.28.7] - 2025-09-23 + +![3.28.7 Release - Hidden Thinking](/releases/3.28.7-release.png) + +- UX: Collapse thinking blocks by default with UI settings to always show them (thanks @brunobergher!) +- Fix: Resolve checkpoint restore popover positioning issue (#8219 by @NaccOll, PR by @app/roomote) +- Add cloud account switcher functionality (thanks @mrubens!) +- Add support for zai-org/GLM-4.5-turbo model in Chutes provider (#8155 by @mugnimaestra, PR by @app/roomote) + +## [3.28.6] - 2025-09-23 + +![3.28.6 Release - Kangaroo studying ancient codex](/releases/3.28.6-release.png) + +- Feat: Add GPT-5-Codex model (thanks @daniel-lxs!) +- Feat: Add keyboard shortcut for toggling auto-approve (Cmd/Ctrl+Alt+A) (thanks @brunobergher!) +- Fix: Improve reasoning block formatting for better readability (thanks @daniel-lxs!) +- Fix: Respect Ollama Modelfile num_ctx configuration (#7797 by @hannesrudolph, PR by @app/roomote) +- Fix: Prevent checkpoint text from wrapping in non-English languages (#8206 by @NaccOll, PR by @app/roomote) +- Remove language selection and word wrap toggle from CodeBlock (thanks @mrubens!) +- Feat: Add package.nls.json checking to find-missing-translations script (thanks @app/roomote!) +- Fix: Bare metal evals fixes (thanks @cte!) +- Fix: Follow-up questions should trigger the "interactive" state (thanks @cte!) + +## [3.28.5] - 2025-09-20 + +![3.28.5 Release - Kangaroo staying hydrated](/releases/3.28.5-release.png) + +- Fix: Resolve duplicate rehydrate during reasoning; centralize rehydrate and preserve cancel metadata (#8153 by @hannesrudolph, PR by @hannesrudolph) +- Add an announcement for Supernova (thanks @mrubens!) +- Wrap code blocks by default for improved readability (thanks @mrubens!) +- Fix: Support dash prefix in parseMarkdownChecklist for todo lists (#8054 by @NaccOll, PR by app/roomote) +- Fix: Apply tiered pricing for Gemini models via Vertex AI (#8017 by @ikumi3, PR by app/roomote) +- Update SambaNova models to latest versions (thanks @snova-jorgep!) +- Update privacy policy to allow occasional emails (thanks @jdilla1277!) + +## [3.28.4] - 2025-09-19 + +![3.28.4 Release - Supernova Discovery](/releases/3.28.4-release.png) + +- UX: Redesigned Message Feed (thanks @brunobergher!) +- UX: Responsive Auto-Approve (thanks @brunobergher!) +- Add telemetry retry queue for network resilience (thanks @daniel-lxs!) +- Fix: Transform keybindings in nightly build to fix command+y shortcut (thanks @app/roomote!) +- New code-supernova stealth model in the Roo Code Router (thanks @mrubens!) + +## [3.28.3] - 2025-09-16 + +![3.28.3 Release - UI/UX Improvements and Bug Fixes](/releases/3.28.3-release.png) + +- Fix: Filter out Claude Code built-in tools (ExitPlanMode, BashOutput, KillBash) (#7817 by @juliettefournier-econ, PR by @roomote) +- Replace + icon with edit icon for New Task button (#7941 by @hannesrudolph, PR by @roomote) +- Fix: Corrected C# tree-sitter query (#5238 by @vadash, PR by @mubeen-zulfiqar) +- Add keyboard shortcut for "Add to Context" action (#7907 by @hannesrudolph, PR by @roomote) +- Fix: Context menu is obscured when edit message (#7759 by @mini2s, PR by @NaccOll) +- Fix: Handle ByteString conversion errors in OpenAI embedders (#7959 by @PavelA85, PR by @daniel-lxs) +- Add Z.ai coding plan support (thanks @daniel-lxs!) +- Move slash commands to Settings tab with gear icon for discoverability (thanks @roomote!) +- Reposition Add Image button inside ChatTextArea (thanks @roomote!) +- Bring back a way to temporarily and globally pause auto-approve without losing your toggle state (thanks @brunobergher!) +- Makes text area buttons appear only when there's text (thanks @brunobergher!) +- CONTRIBUTING.md tweaks and issue template rewrite (thanks @hannesrudolph!) +- Bump axios from 1.9.0 to 1.12.0 (thanks @dependabot!) + +## [3.28.2] - 2025-09-14 + +![3.28.2 Release - Auto-approve improvements](/releases/3.28.2-release.png) + +- Improve auto-approve UI with smaller and more subtle design (thanks @brunobergher!) +- Fix: Message queue re-queue loop in Task.ask() causing performance issues (#7861 by @hannesrudolph, PR by @daniel-lxs) +- Fix: Restrict @-mention parsing to line-start or whitespace boundaries to prevent false triggers (#7875 by @hannesrudolph, PR by @app/roomote) +- Fix: Make nested git repository warning persistent with path info for better visibility (#7884 by @hannesrudolph, PR by @app/roomote) +- Fix: Include API key in Ollama /api/tags requests for authenticated instances (#7902 by @ItsOnlyBinary, PR by @app/roomote) +- Fix: Preserve original first message context during conversation condensing (thanks @daniel-lxs!) +- Add Qwen3 Next 80B A3B models to chutes provider (thanks @daniel-lxs!) +- Disable Roomote Control on logout for better security (thanks @cte!) +- Add padding to the cloudview for better visual spacing (thanks @mrubens!) + +## [3.28.1] - 2025-09-11 + +![3.28.1 Release - Kangaroo riding rocket to the clouds](/releases/3.28.1-release.png) + +- Announce Roo Code Cloud! +- Add cloud task button for opening tasks in Roo Code Cloud (thanks @app/roomote!) +- Make Posthog telemetry the default (thanks @mrubens!) +- Show notification when the checkpoint initialization fails (thanks @app/roomote!) +- Bust cache in generated image preview (thanks @mrubens!) +- Fix: Center active mode in selector dropdown on open (#7882 by @hannesrudolph, PR by @app/roomote) +- Fix: Preserve first message during conversation condensing (thanks @daniel-lxs!) + +## [3.28.0] - 2025-09-10 + +![3.28.0 Release - Continue tasks in Roo Code Cloud](/releases/3.28.0-release.png) + +- feat: Continue tasks in Roo Code Cloud (thanks @brunobergher!) +- feat: Support connecting to Cloud without redirect handling (thanks @mrubens!) +- feat: Add toggle to control task syncing to Cloud (thanks @jr!) +- feat: Add click-to-edit, ESC-to-cancel, and fix padding consistency for chat messages (#7788 by @hannesrudolph, PR by @app/roomote) +- feat: Make reasoning more visible (thanks @app/roomote!) +- fix: Fix Groq context window display (thanks @mrubens!) +- fix: Add GIT_EDITOR env var to merge-resolver mode for non-interactive rebase (thanks @daniel-lxs!) +- fix: Resolve chat message edit/delete duplication issues (thanks @daniel-lxs!) +- fix: Reduce CodeBlock button z-index to prevent overlap with popovers (#7703 by @A0nameless0man, PR by @daniel-lxs) +- fix: Revert PR #7188 - Restore temperature parameter to fix TabbyApi/ExLlamaV2 crashes (#7581 by @drknyt, PR by @daniel-lxs) +- fix: Make ollama models info transport work like lmstudio (#7674 by @ItsOnlyBinary, PR by @ItsOnlyBinary) +- fix: Update DeepSeek pricing to new unified rates effective Sept 5, 2025 (#7685 by @NaccOll, PR by @app/roomote) +- feat: Update Vertex AI models and regions (#7725 by @ssweens, PR by @ssweens) +- chore: Update dependency eslint-plugin-turbo to v2.5.6 (thanks @app/renovate!) +- chore: Update dependency @changesets/cli to v2.29.6 (thanks @app/renovate!) +- chore: Update dependency nock to v14.0.10 (thanks @app/renovate!) +- chore: Update dependency eslint-config-prettier to v10.1.8 (thanks @app/renovate!) +- chore: Update dependency esbuild to v0.25.9 (thanks @app/renovate!) + +## [3.27.0] - 2025-09-05 + +![3.27.0 Release - Bug Fixes and Improvements](/releases/3.27.0-release.png) + +- Add: User message editing and deletion functionality (thanks @NaccOll!) +- Add: Kimi K2-0905 model support in Chutes provider (#7700 by @pwilkin, PR by @app/roomote) +- Fix: Prevent stack overflow in codebase indexing for large projects (#7588 by @StarTrai1, PR by @daniel-lxs) +- Fix: Resolve race condition in Gemini Grounding Sources by improving code design (#6372 by @daniel-lxs, PR by @HahaBill) +- Fix: Preserve conversation context by retrying with full conversation on invalid previous_response_id (thanks @daniel-lxs!) +- Fix: Identify MCP and slash command config path in multiple folder workspaces (#6720 by @kfuglsang, PR by @NaccOll) +- Fix: Handle array paths from VSCode terminal profiles correctly (#7695 by @Amosvcc, PR by @app/roomote) +- Fix: Improve WelcomeView styling and readability (thanks @daniel-lxs!) +- Fix: Resolve CI e2e test ETIMEDOUT errors when downloading VS Code (thanks @daniel-lxs!) + +## [3.26.7] - 2025-09-04 + +![3.26.7 Release - OpenAI Service Tiers](/releases/3.26.7-release.png) + +- Feature: Add OpenAI Responses API service tiers (flex/priority) with UI selector and pricing (thanks @hannesrudolph!) +- Feature: Add DeepInfra as a model provider in Roo Code (#7661 by @Thachnh, PR by @Thachnh) +- Feature: Update kimi-k2-0905-preview and kimi-k2-turbo-preview models on the Moonshot provider (thanks @CellenLee!) +- Feature: Add kimi-k2-0905-preview to Groq, Moonshot, and Fireworks (thanks @daniel-lxs and Cline!) +- Fix: Prevent countdown timer from showing in history for answered follow-up questions (#7624 by @XuyiK, PR by @daniel-lxs) +- Fix: Moonshot's maximum return token count limited to 1024 issue resolved (#6936 by @greyishsong, PR by @wangxiaolong100) +- Fix: Add error transform to cryptic OpenAI SDK errors when API key is invalid (#7483 by @A0nameless0man, PR by @app/roomote) +- Fix: Validate MCP tool exists before execution (#7631 by @R-omk, PR by @app/roomote) +- Fix: Handle zsh glob qualifiers correctly (thanks @mrubens!) +- Fix: Handle zsh process substitution correctly (thanks @mrubens!) +- Fix: Minor zh-TW Traditional Chinese locale typo fix (thanks @PeterDaveHello!) + +## [3.26.6] - 2025-09-03 + +![3.26.6 Release - Bug Fixes and Tool Improvements](/releases/3.26.6-release.png) + +- Add experimental run_slash_command tool to let the model initiate slash commands (thanks @app/roomote!) +- Fix: use askApproval wrapper in insert_content and search_and_replace tools (#7648 by @hannesrudolph, PR by @app/roomote) +- Add Kimi K2 Turbo model configuration to moonshotModels (thanks @wangxiaolong100!) +- Fix: preserve scroll position when switching tabs in settings (thanks @DC-Dancao!) + +## [3.26.5] - 2025-09-03 + +![3.26.5 Release - Enhanced AI Thinking Capabilities](/releases/3.26.5-release.png) + +- feat: Add support for Qwen3 235B A22B Thinking 2507 model in chutes (thanks @mohammad154!) +- feat: Add auto-approve support for MCP access_resource tool (#7565 by @m-ibm, PR by @daniel-lxs) +- feat: Add configurable embedding batch size for code indexing (#7356 by @BenLampson, PR by @app/roomote) +- fix: Add cache reporting support for OpenAI-Native provider (thanks @hannesrudolph!) +- feat: Move message queue to the extension host for better performance (thanks @cte!) + +## [3.26.4] - 2025-09-01 + +![3.26.4 Release - Memory Optimization](/releases/3.26.4-release.png) + +- Optimize memory usage for image handling in webview (thanks @daniel-lxs!) +- Fix: Special tokens should not break task processing (#7539 by @pwilkin, PR by @pwilkin) +- Add Ollama API key support for Turbo mode (#7147 by @LivioGama, PR by @app/roomote) +- Rename Account tab to Cloud tab for clarity (thanks @app/roomote!) +- Add kangaroo-themed release image generation (thanks @mrubens!) + +## [3.26.3] - 2025-08-29 + +![3.26.3 Release - Kangaroo Photo Editor](/releases/3.26.3-release.png) + +- Add optional input image parameter to image generation tool (thanks @roomote!) +- Refactor: Flatten image generation settings structure (thanks @daniel-lxs!) +- Show console logging in vitests when the --no-silent flag is set (thanks @hassoncs!) + +## [3.26.2] - 2025-08-28 + +![3.26.2 Release - Kangaroo Digital Artist](/releases/3.26.2-release.png) + +- feat: Add experimental image generation tool with OpenRouter integration (thanks @daniel-lxs!) +- Fix: Resolve GPT-5 Responses API issues with condensing and image support (#7334 by @nlbuescher, PR by @daniel-lxs) +- Fix: Hide .rooignore'd files from environment details by default (#7368 by @AlexBlack772, PR by @app/roomote) +- Fix: Exclude browser scroll actions from repetition detection (#7470 by @cgrierson-smartsheet, PR by @app/roomote) + +## [3.26.1] - 2025-08-27 + +![3.26.1 Release - Kangaroo Network Engineer](/releases/3.26.1-release.png) + +- Add Vercel AI Gateway provider integration (thanks @joshualipman123!) +- Add support for Vercel embeddings (thanks @mrubens!) +- Enable on-disk storage for Qdrant vectors and HNSW index (thanks @daniel-lxs!) +- Show model ID in API configuration dropdown (thanks @daniel-lxs!) +- Update tooltip component to match native VSCode tooltip shadow styling (thanks @roomote!) +- Fix: remove duplicate cache display in task header (thanks @mrubens!) +- Random chat text area cleanup (thanks @cte!) + +## [3.26.0] - 2025-08-26 + +![3.26.0 Release - Kangaroo Speed Racer](/releases/3.26.0-release.png) + +- Sonic -> Grok Code Fast +- feat: Add Qwen Code CLI API Support with OAuth Authentication (thanks @evinelias and Cline!) +- feat: Add Deepseek v3.1 to Fireworks AI provider (#7374 by @dmarkey, PR by @app/roomote) +- Add a built-in /init slash command (thanks @mrubens and @hannesrudolph!) +- Fix: Make auto approve toggle trigger stay (#3909 by @kyle-apex, PR by @elianiva) +- Fix: Preserve user input when selecting follow-up choices (#7316 by @teihome, PR by @daniel-lxs) +- Fix: Handle Mistral thinking content as reasoning chunks (#6842 by @Biotrioo, PR by @app/roomote) +- Fix: Resolve newTaskRequireTodos setting not working correctly (thanks @hannesrudolph!) +- Fix: Requesty model listing (#7377 by @dtrugman, PR by @dtrugman) +- feat: Hide static providers with no models from provider list (thanks @daniel-lxs!) +- Add todos parameter to new_task tool usage in issue-fixer mode (thanks @hannesrudolph!) +- Handle substitution patterns in command validation (thanks @mrubens!) +- Mark code-workspace files as protected (thanks @mrubens!) +- Update list of default allowed commands (thanks @mrubens!) +- Follow symlinks in rooignore checks (thanks @mrubens!) +- Show cache read and write prices for OpenRouter inference providers (thanks @chrarnoldus!) +- chore(deps): Update dependency drizzle-kit to v0.31.4 (thanks @app/renovate!) + +## [3.25.23] - 2025-08-22 + +- feat: add custom base URL support for Requesty provider (thanks @requesty-JohnCosta27!) +- feat: add DeepSeek V3.1 model to Chutes AI provider (#7294 by @dmarkey, PR by @app/roomote) +- Revert "feat: enable loading Roo modes from multiple files in .roo/modes directory" temporarily to fix a bug with mode installation + +## [3.25.22] - 2025-08-22 + +- Add prompt caching support for Kimi K2 on Groq (thanks @daniel-lxs and @benank!) +- Add documentation links for global custom instructions in UI (thanks @app/roomote!) + +## [3.25.21] - 2025-08-21 + +- Ensure subtask results are provided to GPT-5 in OpenAI Responses API +- Promote the experimental AssistantMessageParser to the default parser +- Update DeepSeek models context window to 128k (thanks @JuanPerezReal) +- Enable grounding features for Vertex AI (thanks @anguslees) +- Allow orchestrator to pass TODO lists to subtasks +- Improved MDM handling +- Handle nullish token values in ContextCondenseRow to prevent UI crash (thanks @s97712) +- Improved context window error handling for OpenAI and other providers +- Add "installed" filter to Roo Marketplace (thanks @semidark) +- Improve filesystem access checks (thanks @elianiva) +- Support for loading Roo modes from multiple YAML files in the `.roo/modes/` directory (thanks @farazoman) +- Add Featherless provider (thanks @DarinVerheijke) + +## [3.25.20] - 2025-08-19 + +- Add announcement for Sonic model + +## [3.25.19] - 2025-08-19 + +- Fix issue where new users couldn't select the Roo Code Router (thanks @daniel-lxs!) + +## [3.25.18] - 2025-08-19 + +- Add new stealth Sonic model through the Roo Code Router +- Fix: respect enableReasoningEffort setting when determining reasoning usage (#7048 by @ikbencasdoei, PR by @app/roomote) +- Fix: prevent duplicate LM Studio models with case-insensitive deduplication (#6954 by @fbuechler, PR by @daniel-lxs) +- Feat: simplify ask_followup_question prompt documentation (thanks @daniel-lxs!) +- Feat: simple read_file tool for single-file-only models (thanks @daniel-lxs!) +- Fix: Add missing zaiApiKey and doubaoApiKey to SECRET_STATE_KEYS (#7082 by @app/roomote) +- Feat: Add new models and update configurations for vscode-lm (thanks @NaccOll!) + +## [3.25.17] - 2025-08-17 + +- Fix: Resolve terminal reuse logic issues + +## [3.25.16] - 2025-08-16 + +- Add support for OpenAI gpt-5-chat-latest model (#7057 by @PeterDaveHello, PR by @app/roomote) +- Fix: Use native Ollama API instead of OpenAI compatibility layer (#7070 by @LivioGama, PR by @daniel-lxs) +- Fix: Prevent XML entity decoding in diff tools (#7107 by @indiesewell, PR by @app/roomote) +- Fix: Add type check before calling .match() on diffItem.content (#6905 by @pwilkin, PR by @app/roomote) +- Refactor task execution system: improve call stack management (thanks @catrielmuller!) +- Fix: Enable save button for provider dropdown and checkbox changes (thanks @daniel-lxs!) +- Add an API for resuming tasks by ID (thanks @mrubens!) +- Emit event when a task ask requires interaction (thanks @cte!) +- Make enhance with task history default to true (thanks @liwilliam2021!) +- Fix: Use cline.cwd as primary source for workspace path in codebaseSearchTool (thanks @NaccOll!) +- Hotfix multiple folder workspace checkpoint (thanks @NaccOll!) + +## [3.25.15] - 2025-08-14 + +- Fix: Remove 500-message limit to prevent scrollbar jumping in long conversations (#7052, #7063 by @daniel-lxs, PR by @app/roomote) +- Fix: Reset condensing state when switching tasks (#6919 by @f14XuanLv, PR by @f14XuanLv) +- Fix: Implement sitemap generation in TypeScript and remove XML file (#5231 by @abumalick, PR by @abumalick) +- Fix: allowedMaxRequests and allowedMaxCost values not showing in the settings UI (thanks @chrarnoldus!) + +## [3.25.14] - 2025-08-13 + +- Fix: Only include verbosity parameter for models that support it (#7054 by @eastonmeth, PR by @app/roomote) +- Fix: Amazon Bedrock 1M context - Move anthropic_beta to additionalModelRequestFields (thanks @daniel-lxs!) +- Fix: Make cancelling requests more responsive by reverting recent changes + +## [3.25.13] - 2025-08-12 + +- Add Sonnet 1M context checkbox to Bedrock +- Fix: add --no-messages flag to ripgrep to suppress file access errors (#6756 by @R-omk, PR by @app/roomote) +- Add support for AGENT.md alongside AGENTS.md (#6912 by @Brendan-Z, PR by @app/roomote) +- Remove deprecated GPT-4.5 Preview model (thanks @PeterDaveHello!) + +## [3.25.12] - 2025-08-12 + +- Update: Claude Sonnet 4 context window configurable to 1 million tokens in Anthropic provider (thanks @daniel-lxs!) +- Add: Minimal reasoning support to OpenRouter (thanks @daniel-lxs!) +- Fix: Add configurable API request timeout for local providers (#6521 by @dabockster, PR by @app/roomote) +- Fix: Add --no-sandbox flag to browser launch options (#6632 by @QuinsZouls, PR by @QuinsZouls) +- Fix: Ensure JSON files respect .rooignore during indexing (#6690 by @evermoving, PR by @app/roomote) +- Add: New Chutes provider models (#6698 by @fstandhartinger, PR by @app/roomote) +- Add: OpenAI gpt-oss models to Amazon Bedrock dropdown (#6752 by @josh-clanton-powerschool, PR by @app/roomote) +- Fix: Correct tool repetition detector to not block first tool call when limit is 1 (#6834 by @NaccOll, PR by @app/roomote) +- Fix: Improve checkpoint service initialization handling (thanks @NaccOll!) +- Update: Improve zh-TW Traditional Chinese locale (thanks @PeterDaveHello!) +- Add: Task expand and collapse translations (thanks @app/roomote!) +- Update: Exclude GPT-5 models from 20% context window output token cap (thanks @app/roomote!) +- Fix: Truncate long model names in model selector to prevent overflow (thanks @app/roomote!) +- Add: Requesty base url support (thanks @requesty-JohnCosta27!) + +## [3.25.11] - 2025-08-11 + +- Add: Native OpenAI provider support for Codex Mini model (#5386 by @KJ7LNW, PR by @daniel-lxs) +- Add: IO Intelligence Provider support (thanks @ertan2002!) +- Fix: MCP startup issues and remove refresh notifications (thanks @hannesrudolph!) +- Fix: Improvements to GPT-5 OpenAI provider configuration (thanks @hannesrudolph!) +- Fix: Clarify codebase_search path parameter as optional and improve tool descriptions (thanks @app/roomote!) +- Fix: Bedrock provider workaround for LiteLLM passthrough issues (thanks @jr!) +- Fix: Token usage and cost being underreported on cancelled requests (thanks @chrarnoldus!) + +## [3.25.10] - 2025-08-07 + +- Add support for GPT-5 (thanks Cline and @app/roomote!) +- Fix: Use CDATA sections in XML examples to prevent parser errors (#4852 by @hannesrudolph, PR by @hannesrudolph) +- Fix: Add missing MCP error translation keys (thanks @app/roomote!) + +## [3.25.9] - 2025-08-07 + +- Fix: Resolve rounding issue with max tokens (#6806 by @markp018, PR by @mrubens) +- Add support for GLM-4.5 and OpenAI gpt-oss models in Fireworks provider (#6753 by @alexfarlander, PR by @app/roomote) +- Improve UX by focusing chat input when clicking plus button in extension menu (thanks @app/roomote!) + +## [3.25.8] - 2025-08-06 + +- Fix: Prevent disabled MCP servers from starting processes and show correct status (#6036 by @hannesrudolph, PR by @app/roomote) +- Fix: Handle current directory path "." correctly in codebase_search tool (#6514 by @hannesrudolph, PR by @app/roomote) +- Fix: Trim whitespace from OpenAI base URL to fix model detection (#6559 by @vauhochzett, PR by @app/roomote) +- Feat: Reduce Gemini 2.5 Pro minimum thinking budget to 128 (thanks @app/roomote!) +- Fix: Improve handling of net::ERR_ABORTED errors in URL fetching (#6632 by @QuinsZouls, PR by @app/roomote) +- Fix: Recover from error state when Qdrant becomes available (#6660 by @hannesrudolph, PR by @app/roomote) +- Fix: Resolve memory leak in ChatView virtual scrolling implementation (thanks @xyOz-dev!) +- Add: Swift files to fallback list (#5857 by @niteshbalusu11, #6555 by @sealad886, PR by @niteshbalusu11) +- Feat: Clamp default model max tokens to 20% of context window (thanks @mrubens!) + +## [3.25.7] - 2025-08-05 + +- Add support for Claude Opus 4.1 +- Add Fireworks AI provider (#6653 by @ershang-fireworks, PR by @ershang-fireworks) +- Add Z AI provider (thanks @jues!) +- Add Groq support for GPT-OSS +- Add Cerebras support for GPT-OSS +- Add code indexing support for multiple folders similar to task history (#6197 by @NaccOll, PR by @NaccOll) +- Make mode selection dropdowns responsive (#6423 by @AyazKaan, PR by @AyazKaan) +- Redesigned task header and task history (thanks @brunobergher!) +- Fix checkpoints timing and ensure checkpoints work properly (#4827 by @mrubens, PR by @NaccOll) +- Fix empty mode names from being saved (#5766 by @kfxmvp, PR by @app/roomote) +- Fix MCP server creation when setting is disabled (#6607 by @characharm, PR by @app/roomote) +- Update highlight layer style and align to textarea (#6647 by @NaccOll, PR by @NaccOll) +- Fix UI for approving chained commands +- Use assistantMessageParser class instead of parseAssistantMessage (#5340 by @qdaxb, PR by @qdaxb) +- Conditionally include reminder section based on todo list config (thanks @NaccOll!) +- Task and TaskProvider event emitter cleanup with new events (thanks @cte!) + +## [3.25.6] - 2025-08-01 + +- Set horizon-beta model max tokens to 32k for OpenRouter (requested by @hannesrudolph, PR by @app/roomote) +- Add support for syncing provider profiles from the cloud + +## [3.25.5] - 2025-08-01 + +- Fix: Improve Claude Code ENOENT error handling with installation guidance (#5866 by @JamieJ1, PR by @app/roomote) +- Fix: LM Studio model context length (#5075 by @Angular-Angel, PR by @pwilkin) +- Fix: VB.NET indexing by implementing fallback chunking system (#6420 by @JensvanZutphen, PR by @daniel-lxs) +- Add auto-approved cost limits (thanks @hassoncs!) +- Add Cerebras as a provider (thanks @kevint-cerebras!) +- Add Qwen 3 Coder from Cerebras (thanks @kevint-cerebras!) +- Fix: Handle Qdrant deletion errors gracefully to prevent indexing interruption (thanks @daniel-lxs!) +- Fix: Restore message sending when clicking save button (thanks @daniel-lxs!) +- Fix: Linter not applied to locales/\*/README.md (thanks @liwilliam2021!) +- Handle more variations of chaining and subshell command validation +- More tolerant search/replace match +- Clean up the auto-approve UI (thanks @mrubens!) +- Skip interpolation for non-existent slash commands (thanks @app/roomote!) + +## [3.25.4] - 2025-07-30 + +- feat: add SambaNova provider integration (#6077 by @snova-jorgep, PR by @snova-jorgep) +- feat: add Doubao provider integration (thanks @AntiMoron!) +- feat: set horizon-alpha model max tokens to 32k for OpenRouter (thanks @app/roomote!) +- feat: add zai-org/GLM-4.5-FP8 model to Chutes AI provider (#6440 by @leakless21, PR by @app/roomote) +- feat: add symlink support for AGENTS.md file loading (thanks @app/roomote!) +- feat: optionally add task history context to prompt enhancement (thanks @liwilliam2021!) +- fix: remove misleading task resumption message (#5850 by @KJ7LNW, PR by @KJ7LNW) +- feat: add pattern to support Databricks /invocations endpoints (thanks @adambrand!) +- fix: resolve navigator global error by updating mammoth and bluebird dependencies (#6356 by @hishtadlut, PR by @app/roomote) +- feat: enhance token counting by extracting text from messages using VSCode LM API (#6112 by @sebinseban, PR by @NaccOll) +- feat: auto-refresh marketplace data when organization settings change (thanks @app/roomote!) +- fix: kill button for execute_command tool (thanks @daniel-lxs!) + +## [3.25.3] - 2025-07-30 + +- Allow queueing messages with images +- Increase Claude Code default max output tokens to 16k (#6125 by @bpeterson1991, PR by @app/roomote) +- Add docs link for slash commands +- Hide Gemini checkboxes on the welcome view +- Clarify apply_diff tool descriptions to emphasize surgical edits +- Fix: Prevent input clearing when clicking chat buttons (thanks @hassoncs!) +- Update PR reviewer rules and mode configuration (thanks @daniel-lxs!) +- Add translation check action to pull_request.opened event (thanks @app/roomote!) +- Remove "(prev Roo Cline)" from extension title in all languages (thanks @app/roomote!) +- Remove event types mention from PR reviewer rules (thanks @daniel-lxs!) + +## [3.25.2] - 2025-07-29 + +- Fix: Show diff view before approval when background edits are disabled (thanks @daniel-lxs!) +- Add support for organization-level MCP controls +- Fix zap icon hover state + +## [3.25.1] - 2025-07-29 + +- Add support for GLM-4.5-Air model to Chutes AI provider (#6376 by @matbgn, PR by @app/roomote) +- Improve subshell validation for commands + +## [3.25.0] - 2025-07-29 + +- Add message queueing (thanks @app/roomote!) +- Add custom slash commands +- Add options for URL Context and Grounding with Google Search to the Gemini provider (thanks @HahaBill!) +- Add image support to read_file tool (thanks @samhvw8!) +- Add experimental setting to prevent editor focus disruption (#4784 by @hannesrudolph, PR by @app/roomote) +- Add prompt caching support for LiteLLM (#5791 by @steve-gore-snapdocs, PR by @MuriloFP) +- Add markdown table rendering support +- Fix list_files recursive mode now works for dot directories (#2992 by @avtc, #4807 by @zhang157686, #5409 by @MuriloFP, PR by @MuriloFP) +- Add search functionality to mode selector popup and reorganize layout +- Sync API config selector style with mode selector +- Fix keyboard shortcuts for non-QWERTY layouts (#6161 by @shlgug, PR by @app/roomote) +- Add ESC key handling for modes, API provider, and indexing settings popovers (thanks @app/roomote!) +- Make task mode sticky to task (thanks @app/roomote!) +- Add text wrapping to command patterns in Manage Command Permissions (thanks @app/roomote!) +- Update list-files test for fixed hidden files bug (thanks @daniel-lxs!) +- Fix normalize Windows paths to forward slashes in mode export (#6307 by @hannesrudolph, PR by @app/roomote) +- Ensure form-data >= 4.0.4 +- Fix filter out non-text tab inputs (Kilo-Org/kilocode#712 by @szermatt, PR by @hassoncs) + +## [3.24.0] - 2025-07-25 + +- Add Hugging Face provider with support for open source models (thanks @TGlide!) +- Add terminal command permissions UI to chat interface +- Add support for Agent Rules standard via AGENTS.md (thanks @sgryphon!) +- Add settings to control diagnostic messages +- Fix auto-approve checkbox to be toggled at any time (thanks @KJ7LNW!) +- Add efficiency warning for single SEARCH/REPLACE blocks in apply_diff (thanks @KJ7LNW!) +- Fix respect maxReadFileLine setting for file mentions to prevent context exhaustion (thanks @sebinseban!) +- Fix Ollama API URL normalization by removing trailing slashes (thanks @Naam!) +- Fix restore list styles for markdown lists in chat interface (thanks @village-way!) +- Add support for bedrock api keys +- Add confirmation dialog and proper cleanup for marketplace mode removal +- Fix cancel auto-approve timer when editing follow-up suggestion (thanks @hassoncs!) +- Fix add error message when no workspace folder is open for code indexing + +## [3.23.19] - 2025-07-23 + +- Add Roo Code Cloud Waitlist CTAs (thanks @brunobergher!) +- Split commands on newlines when evaluating auto-approve +- Smarter auto-deny of commands + +## [3.23.18] - 2025-07-23 + +- Fix: Resolve 'Bad substitution' error in command parsing (#5978 by @KJ7LNW, PR by @daniel-lxs) +- Fix: Add ErrorBoundary component for better error handling (#5731 by @elianiva, PR by @KJ7LNW) +- Fix: Todo list toggle not working (thanks @chrarnoldus!) +- Improve: Use SIGKILL for command execution timeouts in the "execa" variant (thanks @cte!) + +## [3.23.17] - 2025-07-22 + +- Add: todo list tool enable checkbox to provider advanced settings +- Add: Moonshot provider (thanks @CellenLee!) +- Add: Qwen/Qwen3-235B-A22B-Instruct-2507 model to Chutes AI provider +- Fix: move context condensing prompt to Prompts section (thanks @SannidhyaSah!) +- Add: jump icon for newly created files +- Fix: add character limit to prevent terminal output context explosion +- Fix: resolve global mode export not including rules files +- Fix: enable export, share, and copy buttons during API operations (thanks @MuriloFP!) +- Add: configurable timeout for evals (5-10 min) +- Add: auto-omit MCP content when no servers are configured +- Fix: sort symlinked rules files by symlink names, not target names +- Docs: clarify when to use update_todo_list tool +- Add: Mistral embedding provider (thanks @SannidhyaSah!) +- Fix: add run parameter to vitest command in rules (thanks @KJ7LNW!) +- Update: the max_tokens fallback logic in the sliding window +- Fix: Bedrock and Vertex token counting improvements (thanks @daniel-lxs!) +- Add: llama-4-maverick model to Vertex AI provider (thanks @MuriloFP!) +- Fix: properly distinguish between user cancellations and API failures +- Fix: add case sensitivity mention to suggested fixes in apply_diff error message + +## [3.23.16] - 2025-07-19 + +- Add global rate limiting for OpenAI-compatible embeddings (thanks @daniel-lxs!) +- Add batch limiting to code indexer (thanks @daniel-lxs!) +- Fix Docker port conflicts for evals services + +## [3.23.15] - 2025-07-18 + +- Fix configurable delay for diagnostics to prevent premature error reporting +- Add command timeout allowlist +- Add description and whenToUse fields to custom modes in .roomodes (thanks @RandalSchwartz!) +- Fix Claude model detection by name for API protocol selection (thanks @daniel-lxs!) +- Move marketplace icon from overflow menu to top navigation +- Optional setting to prevent completion with open todos +- Added YouTube to website footer (thanks @thill2323!) + +## [3.23.14] - 2025-07-17 + +- Log api-initiated tasks to a tmp directory + +## [3.23.13] - 2025-07-17 + +- Add the ability to "undo" enhance prompt changes +- Fix a bug where the path component of the baseURL for the LiteLLM provider contains path in it (thanks @ChuKhaLi) +- Add support for Vertex AI model name formatting when using Claude Code with Vertex AI (thanks @janaki-sasidhar) +- The list-files tool must include at least the first-level directory contents (thanks @qdaxb) +- Add a configurable limit that controls both consecutive errors and tool repetitions (thanks @MuriloFP) +- Add `.terraform/` and `.terragrunt-cache/` directories to the checkpoint exclusion patterns (thanks @MuriloFP) +- Increase Ollama API timeout values (thanks @daniel-lxs) +- Fix an issue where you need to "discard changes" before saving even though there are no settings changes +- Fix `DirectoryScanner` memory leak and improve file limit handling (thanks @daniel-lxs) +- Fix time formatting in environment (thanks @chrarnoldus) +- Prevent empty mode names from being saved (thanks @daniel-lxs) +- Improve auto-approve checkbox UX +- Improve the chat message edit / delete functionality (thanks @liwilliam2021) +- Add `commandExecutionTimeout` to `GlobalSettings` + +## [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!) +- Add gemini-embedding-001 model to code-index service (thanks @daniel-lxs!) +- Resolve vector dimension mismatch error when switching embedding models +- Return the cwd in the exec tool's response so that the model is not lost after subsequent calls (thanks @chris-garrett!) +- Add configurable timeout for command execution in VS Code settings + +## [3.23.8] - 2025-07-13 + +- Add enable/disable toggle for code indexing (thanks @daniel-lxs!) +- Add a command auto-deny list to auto-approve settings +- Add navigation link to history tab in HistoryPreview + +## [3.23.7] - 2025-07-11 + +- Fix Mermaid syntax warning (thanks @MuriloFP!) +- Expand Vertex AI region config to include all available regions in GCP Vertex AI (thanks @shubhamgupta731!) +- Handle Qdrant vector dimension mismatch when switching embedding models (thanks @daniel-lxs!) +- Fix typos in comment & document (thanks @noritaka1166!) +- Improve the display of codebase search results +- Correct translation fallback logic for embedding errors (thanks @daniel-lxs!) +- Clean up MCP tool disabling +- Link to marketplace from modes and MCP tab +- Fix TTS button display (thanks @sensei-woo!) +- Add Devstral Medium model support +- Add comprehensive error telemetry to code-index service (thanks @daniel-lxs!) +- Exclude cache tokens from context window calculation (thanks @daniel-lxs!) +- Enable dynamic tool selection in architect mode for context discovery +- Add configurable max output tokens setting for claude-code + +## [3.23.6] - 2025-07-10 + +- Grok 4 + +## [3.23.5] - 2025-07-09 + +- Fix: use decodeURIComponent in openFile (thanks @vivekfyi!) +- Fix(embeddings): Translate error messages before sending to UI (thanks @daniel-lxs!) +- Make account tab visible + +## [3.23.4] - 2025-07-09 + +- Update chat area icons for better discoverability & consistency +- Fix a bug that allowed `list_files` to return directory results that should be excluded by .gitignore +- Add an overflow header menu to make the UI a little tidier (thanks @dlab-anton) +- Fix a bug the issue where null custom modes configuration files cause a 'Cannot read properties of null' error (thanks @daniel-lxs!) +- Replace native title attributes with StandardTooltip component for consistency (thanks @daniel-lxs!) + +## [3.23.3] - 2025-07-09 + +- Remove erroneous line from announcement modal + +## [3.23.2] - 2025-07-09 + +- Fix bug where auto-approval was intermittently failing + +## [3.23.1] - 2025-07-09 + +- Always show the code indexing dot under the chat text area + +## [3.23.0] - 2025-07-08 + +- Move codebase indexing out of experimental (thanks @daniel-lxs and @MuriloFP!) +- Add todo list tool (thanks @qdaxb!) +- Fix code index secret persistence and improve settings UX (thanks @daniel-lxs!) +- Add Gemini embedding provider for codebase indexing (thanks @SannidhyaSah!) +- Support full endpoint URLs in OpenAI Compatible provider (thanks @SannidhyaSah!) +- Add markdown support to codebase indexing (thanks @MuriloFP!) +- Add Search/Filter Functionality to API Provider Selection in Settings (thanks @GOODBOY008!) +- Add configurable max search results (thanks @MuriloFP!) +- Add copy prompt button to task actions (thanks @Juice10 and @vultrnerd!) +- Fix insertContentTool to create new files with content (thanks @Ruakij!) +- Fix typescript compiler watch path inconsistency (thanks @bbenshalom!) +- Use actual max_completion_tokens from OpenRouter API (thanks @shariqriazz!) +- Prevent completion sound from replaying when reopening completed tasks (thanks @SannidhyaSah!) +- Fix access_mcp_resource fails to handle images correctly (thanks @s97712!) +- Prevent chatbox focus loss during automated file editing (thanks @hannesrudolph!) +- Resolve intermittent hangs and lack of clear error feedback in apply_diff tool (thanks @lhish!) +- Resolve Go duplicate references in tree-sitter queries (thanks @MuriloFP!) +- Chat UI consistency and layout shifts (thanks @seedlord!) +- Chat index UI enhancements (thanks @MuriloFP!) +- Fix model search being prefilled on dropdown (thanks @kevinvandijk!) +- Improve chat UI - add camera icon margin and make placeholder non-selectable (thanks @MuriloFP!) +- Delete .roo/rules-{mode} folder when custom mode is deleted +- Enforce file restrictions for all edit tools in architect mode +- Add User-Agent header to API providers +- Fix auto question timer unmount (thanks @liwilliam2021!) +- Fix new_task tool streaming issue +- Optimize file listing when maxWorkspaceFiles is 0 (thanks @daniel-lxs!) +- Correct export/import of OpenAI Compatible codebase indexing settings (thanks @MuriloFP!) +- Resolve workspace path inconsistency in code indexing for multi-workspace scenarios + +## [3.22.6] - 2025-07-02 + +- Add timer-based auto approve for follow up questions (thanks @liwilliam2021!) +- Add import/export modes functionality +- Add persistent version indicator on chat screen +- Add automatic configuration import on extension startup (thanks @takakoutso!) +- Add user-configurable search score threshold slider for semantic search (thanks @hannesrudolph!) +- Add default headers and testing for litellm fetcher (thanks @andrewshu2000!) +- Fix consistent cancellation error messages for thinking vs streaming phases +- Fix Amazon Bedrock cross-region inference profile mapping (thanks @KevinZhao!) +- Fix URL loading timeout issues in @ mentions (thanks @MuriloFP!) +- Fix API retry exponential backoff capped at 10 minutes (thanks @MuriloFP!) +- Fix Qdrant URL field auto-filling with default value (thanks @SannidhyaSah!) +- Fix profile context condensation threshold (thanks @PaperBoardOfficial!) +- Fix apply_diff tool documentation for multi-file capabilities +- Fix cache files excluded from rules compilation (thanks @MuriloFP!) +- Add streamlined extension installation and documentation (thanks @devxpain!) +- Prevent Architect mode from providing time estimates +- Remove context size from environment details +- Change default mode to architect for new installations +- Suppress Mermaid error rendering +- Improve Mermaid buttons with light background in light mode (thanks @chrarnoldus!) +- Add .vscode/ to write-protected files/directories +- Update Amazon Bedrock cross-region inference profile mapping (thanks @KevinZhao!) + +## [3.22.5] - 2025-06-28 + +- Remove Gemini CLI provider while we work with Google on a better integration + +## [3.22.4] - 2025-06-27 + +- Fix: resolve E2BIG error by passing large prompts via stdin to Claude CLI (thanks @Fovty!) +- Add optional mode suggestions to follow-up questions +- Fix: move StandardTooltip inside PopoverTrigger in ShareButton (thanks @daniel-lxs!) + +## [3.22.3] - 2025-06-27 + +- Restore JSON backwards compatibility for .roomodes files (thanks @daniel-lxs!) + +## [3.22.2] - 2025-06-27 + +- Fix: eliminate XSS vulnerability in CodeBlock component (thanks @KJ7LNW!) +- Fix terminal keyboard shortcut error when adding content to context (thanks @MuriloFP!) +- Fix checkpoint popover not opening due to StandardTooltip wrapper conflict (thanks @daniel-lxs!) +- Fix(i18n): correct gemini cli error translation paths (thanks @daniel-lxs!) +- Code Index (Qdrant) recreate services when change configurations (thanks @catrielmuller!) + +## [3.22.1] - 2025-06-26 + +- Add Gemini CLI provider (thanks Cline!) +- Fix undefined mcp command (thanks @qdaxb!) +- Use upstream_inference_cost for OpenRouter BYOK cost calculation and show cached token count (thanks @chrarnoldus!) +- Update maxTokens value for qwen/qwen3-32b model on Groq (thanks @KanTakahiro!) +- Standardize tooltip delays to 300ms + +## [3.22.0] - 2025-06-25 + +- Add 1-click task sharing +- Add support for loading rules from a global .roo directory (thanks @samhvw8!) +- Modes selector improvements (thanks @brunobergher!) +- Use safeWriteJson for all JSON file writes to avoid task history corruption (thanks @KJ7LNW!) +- Improve YAML error handling when editing modes +- Register importSettings as VSCode command (thanks @shivamd1810!) +- Add default task names for empty tasks (thanks @daniel-lxs!) +- Improve translation workflow to avoid unnecessary file reads (thanks @KJ7LNW!) +- Allow write_to_file to handle newline-only and empty content (thanks @Githubguy132010!) +- Address multiple memory leaks in CodeBlock component (thanks @kiwina!) +- Memory cleanup (thanks @xyOz-dev!) +- Fix port handling bug in code indexing for HTTPS URLs (thanks @benashby!) +- Improve Bedrock error handling for throttling and streaming contexts +- Handle long Claude code messages (thanks @daniel-lxs!) +- Fixes to Claude Code caching and image upload +- Disable reasoning budget UI controls for Claude Code provider +- Remove temperature parameter for Azure OpenAI reasoning models (thanks @ExactDoug!) +- Allowed commands import/export (thanks @catrielmuller!) +- Add VS Code setting to disable quick fix context actions (thanks @OlegOAndreev!) + +## [3.21.5] - 2025-06-23 + +- Fix Qdrant URL prefix handling for QdrantClient initialization (thanks @CW-B-W!) +- Improve LM Studio model detection to show all downloaded models (thanks @daniel-lxs!) +- Resolve Claude Code provider JSON parsing and reasoning block display + +## [3.21.4] - 2025-06-23 + +- Fix start line not working in multiple apply diff (thanks @samhvw8!) +- Resolve diff editor issues with markdown preview associations (thanks @daniel-lxs!) +- Resolve URL port handling bug for HTTPS URLs in Qdrant (thanks @benashby!) +- Mark unused Ollama schema properties as optional (thanks @daniel-lxs!) +- Close the local browser when used as fallback for remote (thanks @markijbema!) +- Add Claude Code provider for local CLI integration (thanks @BarreiroT!) + +## [3.21.3] - 2025-06-21 + +- Add profile-specific context condensing thresholds (thanks @SannidhyaSah!) +- Fix context length for lmstudio and ollama (thanks @thecolorblue!) +- Resolve MCP tool eye icon state and hide in chat context (thanks @daniel-lxs!) + +## [3.21.2] - 2025-06-20 + +- Add LaTeX math equation rendering in chat window +- Add toggle for excluding MCP server tools from the prompt (thanks @Rexarrior!) +- Add symlink support to list_files tool +- Fix marketplace blanking after populating +- Fix recursive directory scanning in @ mention "Add Folder" functionality (thanks @village-way!) +- Resolve phantom subtask display on cancel during API retry +- Correct Gemini 2.5 Flash pricing (thanks @daniel-lxs!) +- Resolve marketplace timeout issues and display installed MCPs (thanks @daniel-lxs!) +- Onboarding tweaks to emphasize modes (thanks @brunobergher!) +- Rename 'Boomerang Tasks' to 'Task Orchestration' for clarity +- Remove command execution from attempt_completion +- Fix markdown for links followed by punctuation (thanks @xyOz-dev!) + +## [3.21.1] - 2025-06-19 + +- Fix tree-sitter issues that were preventing codebase indexing from working correctly +- Improve error handling for codebase search embeddings +- Resolve MCP server execution on Windows with node version managers +- Default 'Enable MCP Server Creation' to false +- Rate limit correctly when starting a subtask (thanks @olweraltuve!) + +## [3.21.0] - 2025-06-17 + +- Add Roo Marketplace to make it easy to discover and install great MCPs and modes! +- Add Gemini 2.5 models (Pro, Flash and Flash Lite) (thanks @daniel-lxs!) +- Add support for Excel (.xlsx) files in tools (thanks @chrarnoldus!) +- Add max tokens checkbox option for OpenAI compatible provider (thanks @AlexandruSmirnov!) +- Update provider models and prices for Groq & Mistral (thanks @KanTakahiro!) +- Add proper error handling for API conversation history issues (thanks @KJ7LNW!) +- Fix ambiguous model id error (thanks @elianiva!) +- Fix save/discard/revert flow for Prompt Settings (thanks @hassoncs!) +- Fix codebase indexing alignment with list-files hidden directory filtering (thanks @daniel-lxs!) +- Fix subtask completion mismatch (thanks @feifei325!) +- Fix Windows path normalization in MCP variable injection (thanks @daniel-lxs!) +- Update marketplace branding to 'Roo Marketplace' (thanks @SannidhyaSah!) +- Refactor to more consistent history UI (thanks @elianiva!) +- Adjust context menu positioning to be near Copilot +- Update evals Docker setup to work on Windows (thanks @StevenTCramer!) +- Include current working directory in terminal details +- Encourage use of start_line in multi-file diff to match legacy diff +- Always focus the panel when clicked to ensure menu buttons are visible (thanks @hassoncs!) + +## [3.20.3] - 2025-06-13 + +- Resolve diff editor race condition in multi-monitor setups (thanks @daniel-lxs!) +- Add logic to prevent auto-approving edits of configuration files +- Adjust searching and listing files outside of the workspace to respect the auto-approve settings +- Add Indonesian translation support (thanks @chrarnoldus and @daniel-lxs!) +- Fix multi-file diff error handling and UI feedback (thanks @daniel-lxs!) +- Improve prompt history navigation to not interfere with text editing (thanks @daniel-lxs!) +- Fix errant maxReadFileLine default + +## [3.20.2] - 2025-06-13 + +- Limit search_files to only look within the workspace for improved security +- Force tar-fs >=2.1.3 for security vulnerability fix +- Add cache breakpoints for custom vertex models on Unbound (thanks @pugazhendhi-m!) +- Reapply reasoning for bedrock with fix (thanks @daniel-lxs!) +- Sync BatchDiffApproval styling with BatchFilePermission for UI consistency (thanks @samhvw8!) +- Add max height constraint to MCP execution response for better UX (thanks @samhvw8!) +- Prevent MCP 'installed' label from being squeezed #4630 (thanks @daniel-lxs!) +- Allow a lower context condensing threshold (thanks @SECKainersdorfer!) +- Avoid type system duplication for cleaner codebase (thanks @EamonNerbonne!) + +## [3.20.1] - 2025-06-12 + +- Temporarily revert thinking support for Bedrock models +- Improve performance of MCP execution block +- Add indexing status badge to chat view + +## [3.20.0] - 2025-06-12 + +- Add experimental Marketplace for extensions and modes (thanks @Smartsheet-JB-Brown, @elianiva, @monkeyDluffy6017, @NamesMT, @daniel-lxs, Cline, and more!) +- Add experimental multi-file edits (thanks @samhvw8!) +- Move concurrent reads setting to context settings with default of 5 +- Improve MCP execution UX (thanks @samhvw8!) +- Add magic variables support for MCPs with `workspaceFolder` injection (thanks @NamesMT!) +- Add prompt history navigation via arrow up/down in prompt field +- Add support for escaping context mentions (thanks @KJ7LNW!) +- Add DeepSeek R1 support to Chutes provider +- Add reasoning budget support to Bedrock models for extended thinking +- Add mermaid diagram support buttons (thanks @qdaxb!) +- Update XAI models and pricing (thanks @edwin-truthsearch-io!) +- Update O3 model pricing +- Add manual OpenAI-compatible format specification and parsing (thanks @dflatline!) +- Add core tools integration tests for comprehensive coverage +- Add JSDoc documentation for ClineAsk and ClineSay types (thanks @hannesrudolph!) +- Populate whenToUse descriptions for built-in modes +- Fix file write tool with early relPath & newContent validation checks (thanks @Ruakij!) +- Fix TaskItem display and copy issues with HTML tags in task messages (thanks @forestyoo!) +- Fix OpenRouter cost calculation with BYOK (thanks @chrarnoldus!) +- Fix terminal busy state reset after manual commands complete +- Fix undefined output on multi-file apply_diff operations (thanks @daniel-lxs!) + +## [3.19.7] - 2025-06-11 + +- Fix McpHub sidebar focus behavior to prevent unwanted focus grabbing +- Disable checkpoint functionality when nested git repositories are detected to prevent conflicts +- Remove unused Storybook components and dependencies to reduce bundle size +- Add data-testid ESLint rule for improved testing standards (thanks @elianiva!) +- Update development dependencies including eslint, knip, @types/node, i18next, fast-xml-parser, and @google/genai +- Improve CI infrastructure with GitHub Actions and Blacksmith runner migrations + +## [3.19.6] - 2025-06-09 + +- Replace explicit caching with implicit caching to reduce latency for Gemini models +- Clarify that the default concurrent file read limit is 15 files (thanks @olearycrew!) +- Fix copy button logic (thanks @samhvw8!) +- Fade buttons on history preview if no interaction in progress (thanks @sachasayan!) +- Allow MCP server refreshing, fix state changes in MCP server management UI view (thanks @taylorwilsdon!) +- Remove unnecessary npx usage in some npm scripts (thanks @user202729!) +- Bug fix for trailing slash error when using LiteLLM provider (thanks @kcwhite!) + +## [3.19.5] - 2025-06-05 + +- Fix Gemini 2.5 Pro Preview thinking budget bug + +## [3.19.4] - 2025-06-05 + +- Add Gemini Pro 06-05 model support (thanks @daniel-lxs and @shariqriazz!) +- Fix reading PDF, DOCX, and IPYNB files in read_file tool (thanks @samhvw8!) +- Fix Mermaid CSP errors with enhanced bundling strategy (thanks @KJ7LNW!) +- Improve model info detection for custom Bedrock ARNs (thanks @adamhill!) +- Add OpenAI Compatible embedder for codebase indexing (thanks @SannidhyaSah!) +- Fix multiple memory leaks in ChatView component (thanks @kiwina!) +- Fix WorkspaceTracker resource leaks by disposing FileSystemWatcher (thanks @kiwina!) +- Fix RooTips setTimeout cleanup to prevent state updates on unmounted components (thanks @kiwina!) +- Fix FileSystemWatcher leak in RooIgnoreController (thanks @kiwina!) +- Fix clipboard memory leak by clearing setTimeout in useCopyToClipboard (thanks @kiwina!) +- Fix ClineProvider instance cleanup (thanks @xyOz-dev!) +- Enforce codebase_search as primary tool for code understanding tasks (thanks @hannesrudolph!) +- Improve Docker setup for evals +- Move evals into pnpm workspace, switch from SQLite to Postgres +- Refactor MCP to use getDefaultEnvironment for stdio client transport (thanks @samhvw8!) +- Get rid of "partial" component in names referencing not necessarily partial messages (thanks @wkordalski!) +- Improve feature request template (thanks @elianiva!) + +## [3.19.3] - 2025-06-02 + +- Fix SSE MCP Invocation - Fixed SSE connection issue in McpHub.ts by ensuring transport.start override only applies to stdio transports, allowing SSE and streamable-http transports to retain their original start methods (thanks @taylorwilsdon!) + +## [3.19.2] - 2025-06-01 + +- Add support for Streamable HTTP Transport MCP servers (thanks @taylorwilsdon!) +- Add cached read and writes to stats and cost calculation for LiteLLM provider (thanks @mollux!) +- Prevent dump of an entire file into the context on user edit (thanks @KJ7LNW!) +- Fix directory link handling in markdown (thanks @KJ7LNW!) +- Prevent start_line/end_line in apply_diff REPLACE (thanks @KJ7LNW!) +- Unify history item UI with TaskItem and TaskItemHeader (thanks @KJ7LNW!) +- Fix the label of the OpenAI-compatible API keys +- Fix Virtuoso footer re-rendering issue (thanks @kiwina!) +- Optimize ChatRowContent layout and styles (thanks @zhangtony239!) +- Release memory in apply diff (thanks @xyOz-dev!) +- Upgrade Node.js to v20.19.2 for security enhancements (thanks @PeterDaveHello!) +- Fix typos (thanks @noritaka1166!) + +## [3.19.1] - 2025-05-30 + +- Experimental feature to allow reading multiple files at once (thanks @samhvw8!) +- Fix to correctly pass headers to SSE MCP servers +- Adding support for custom VPC endpoints when using Amazon Bedrock (thanks @kcwhite!) +- Fix bug with context condensing in Amazon Bedrock +- Fix UTF-8 encoding in ExecaTerminalProcess (thanks @mr-ryan-james!) +- Set sidebar name bugfix (thanks @chrarnoldus!) +- Fix link to CONTRIBUTING.md in feature request template (thanks @cannuri!) +- Add task metadata to Unbound and improve caching logic (thanks @pugazhendhi-m!) + +## [3.19.0] - 2025-05-29 + +- Enable intelligent content condensing by default and move condense button out of expanded task menu +- Skip condense and show error if context grows during condensing +- Transform Prompts tab into Modes tab and move support prompts to Settings for better organization +- Add DeepSeek R1 0528 model support to Chutes provider (thanks @zeozeozeo!) +- Fix @directory not respecting .rooignore files (thanks @xyOz-dev!) +- Add rooignore checking for insert_content and search_and_replace tools +- Fix menu breaking when Roo is moved between primary and secondary sidebars (thanks @chrarnoldus!) +- Resolve memory leak in ChatView by stabilizing callback props (thanks @samhvw8!) +- Fix write_to_file to properly create empty files when content is empty (thanks @Ruakij!) +- Fix chat input clearing during running tasks (thanks @xyOz-dev!) +- Update AWS regions to include Spain and Hyderabad +- Improve POSIX shell compatibility in pre-push hook (thanks @PeterDaveHello and @chrarnoldus!) +- Update PAGER environment variable for Windows compatibility in Terminal (thanks @SmartManoj!) +- Add environment variable injection support for whole MCP config (thanks @NamesMT!) +- Update codebase search description to emphasize English query requirements (thanks @ChuKhaLi!) + +## [3.18.5] - 2025-05-27 + +- Add thinking controls for Requesty (thanks @dtrugman!) +- Re-enable telemetry +- Improve zh-TW Traditional Chinese locale (thanks @PeterDaveHello and @chrarnoldus!) +- Improve model metadata for LiteLLM + +## [3.18.4] - 2025-05-25 + +- Fix codebase indexing settings saving and Ollama indexing (thanks @daniel-lxs!) +- Fix handling BOM when user rejects apply_diff (thanks @avtc!) +- Fix wrongfully clearing input on auto-approve (thanks @Ruakij!) +- Fix correct spawnSync parameters for pnpm check in bootstrap.mjs (thanks @ChuKhaLi!) +- Update xAI models and default model ID (thanks @PeterDaveHello!) +- Add metadata to create message (thanks @dtrugman!) + +## [3.18.3] - 2025-05-24 + +- Add reasoning support for Claude 4 and Gemini 2.5 Flash on OpenRouter, plus a fix for o1-pro +- Add experimental codebase indexing + semantic search feature (thanks @daniel-lxs!) +- For providers that used to default to Sonnet 3.7, change to Sonnet 4 +- Enable prompt caching for Gemini 2.5 Flash Preview (thanks @shariqriazz!) +- Preserve model settings when selecting a specific OpenRouter provider +- Add ability to refresh LiteLLM models list +- Improve tool descriptions to guide proper file editing tool selection +- Fix MCP Server error loading config when running with npx and bunx (thanks @devxpain!) +- Improve pnpm bootstrapping and add compile script (thanks @KJ7LNW!) +- Simplify object assignment & use startsWith (thanks @noritaka1166!) +- Fix mark-as-read logic in the context tracker (thanks @samhvw8!) +- Remove deprecated claude-3.7-sonnet models from vscodelm (thanks @shariqriazz!) + +## [3.18.2] - 2025-05-23 + +- Fix vscode-material-icons in the file picker +- Fix global settings export +- Respect user-configured terminal integration timeout (thanks @KJ7LNW) +- Context condensing enhancements (thanks @SannidhyaSah) + +## [3.18.1] - 2025-05-22 + +- Add support for Claude Sonnet 4 and Claude Opus 4 models with thinking variants in Anthropic, Bedrock, and Vertex (thanks @shariqriazz!) +- Fix README gif display in all localized versions +- Fix referer URL +- Switch codebase to a monorepo and create an automated "nightly" build + +## [3.18.0] - 2025-05-21 + +- Add support for Gemini 2.5 Flash preview models (thanks @shariqriazz and @daniel-lxs!) +- Add button to task header to intelligently condense content with visual feedback +- Add YAML support for mode definitions (thanks @R-omk!) +- Add allowedMaxRequests feature to cap consecutive auto-approved requests (inspired by Cline, thanks @hassoncs!) +- Add Qwen3 model series to the Chutes provider (thanks @zeozeozeo!) +- Fix more causes of grey screen issues (thanks @xyOz-dev!) +- Add LM Studio reasoning support (thanks @avtc!) +- Add refresh models button for Unbound provider (thanks @pugazhendhi-m!) +- Add template variables for version numbers in announcement strings (thanks @ChuKhaLi!) +- Make prompt input textareas resizable again +- Fix diffview scroll display (thanks @qdaxb!) +- Fix LM Studio and Ollama usage tracking (thanks @xyOz-dev!) +- Fix links to filename:0 (thanks @RSO!) +- Fix missing or inconsistent syntax highlighting across UI components (thanks @KJ7LNW!) +- Fix packaging to include correct tiktoken.wasm (thanks @vagadiya!) +- Fix import settings bugs and position error messages correctly (thanks @ChuKhaLi!) +- Move audio playing to the webview to ensure cross-platform support (thanks @SmartManoj and @samhvw8!) +- Simplify loop syntax in multiple components (thanks @noritaka1166!) +- Auto reload extension core changes in dev mode (thanks @hassoncs!) + +## [3.17.2] - 2025-05-15 + +- Revert "Switch to the new Roo message parser" (appears to cause a tool parsing bug) +- Lock the versions of vsce and ovsx + +## [3.17.1] - 2025-05-15 + +- Fix the display of the command to execute during approval +- Fix incorrect reserved tokens calculation on OpenRouter (thanks @daniel-lxs!) + +## [3.17.0] - 2025-05-14 + +- Enable Gemini implicit caching +- Add "when to use" section to mode definitions to enable better orchestration +- Add experimental feature to intelligently condense the task context instead of truncating it +- Fix one of the causes of the gray screen issue (thanks @xyOz-dev!) +- Focus improvements for better UI interactions (thanks Cline!) +- Switch to the new Roo message parser for improved performance (thanks Cline!) +- Enable source maps for improved debugging (thanks @KJ7LNW!) +- Update OpenRouter provider to use provider-specific model info (thanks @daniel-lxs!) +- Fix Requesty cost/token reporting (thanks @dtrugman!) +- Improve command execution UI +- Add more in-app links to relevant documentation +- Update the new task tool description and the ask mode custom instructions in the system prompt +- Add IPC types to roo-code.d.ts +- Add build VSIX workflow to pull requests (thanks @SmartManoj!) +- Improve apply_diff tool to intelligently deduce line numbers (thanks @samhvw8!) +- Fix command validation for shell array indexing (thanks @KJ7LNW!) +- Handle diagnostics that point at a directory URI (thanks @daniel-lxs!) +- Fix "Current ask promise was ignored" error (thanks @zxdvd!) + +## [3.16.6] - 2025-05-12 + +- Restore "Improve provider profile management in the external API" +- Fix to subtask sequencing (thanks @wkordalski!) +- Fix webview terminal output processing error (thanks @KJ7LNW!) +- Fix textarea empty string fallback logic (thanks @elianiva!) + +## [3.16.5] - 2025-05-10 + +- Revert "Improve provider profile management in the external API" until we track down a bug with defaults + +## [3.16.4] - 2025-05-09 + +- Improve provider profile management in the external API +- Enforce provider selection in OpenRouter by using 'only' parameter and disabling fallbacks (thanks @shariqriazz!) +- Fix display issues with long profile names (thanks @cannuri!) +- Prevent terminal focus theft on paste after command execution (thanks @MuriloFP!) +- Save OpenAI compatible custom headers correctly +- Fix race condition when updating prompts (thanks @elianiva!) +- Fix display issues in high contrast themes (thanks @zhangtony239!) +- Fix not being able to use specific providers on Openrouter (thanks @daniel-lxs!) +- Show properly formatted multi-line commands in preview (thanks @KJ7LNW!) +- Handle unsupported language errors gracefully in read_file tool (thanks @KJ7LNW!) +- Enhance focus styles in select-dropdown and fix docs URL (thanks @zhangtony239!) +- Properly handle mode name overflow in UI (thanks @elianiva!) +- Fix project MCP always allow issue (thanks @aheizi!) + +## [3.16.3] - 2025-05-08 + +- Revert Tailwind migration while we fix a few spots +- Add Elixir file extension support in language parser (thanks @pfitz!) + +## [3.16.2] - 2025-05-07 + +- Clarify XML tool use formatting instructions +- Error handling code cleanup (thanks @monkeyDluffy6017!) + +## [3.16.1] - 2025-05-07 + +- Add LiteLLM provider support +- Improve stability by detecting and preventing tool loops +- Add Dutch localization (thanks @Githubguy132010!) +- Add editor name to telemetry for better analytics +- Migrate to Tailwind CSS for improved UI consistency +- Fix footer button wrapping in About section on narrow screens (thanks @ecmasx!) +- Update evals defaults +- Update dependencies to latest versions + +## [3.16.0] - 2025-05-06 + +- Add vertical tab navigation to the settings (thanks @dlab-anton) +- Add Groq and Chutes API providers (thanks @shariqriazz) +- Clickable code references in code block (thanks @KJ7LNW) +- Improve accessibility of auto-approve toggles (thanks @Deon588) +- Requesty provider fixes (thanks @dtrugman) +- Fix migration and persistence of per-mode API profiles (thanks @alasano) +- Fix usage of `path.basename` in the extension webview (thanks @samhvw8) +- Fix display issue of the programming language dropdown in the code block component (thanks @zhangtony239) +- MCP server errors are now captured and shown in a new "Errors" tab (thanks @robertheadley) +- Error logging will no longer break MCP functionality if the server is properly connected (thanks @ksze) +- You can now toggle the `terminal.integrated.inheritEnv` VSCode setting directly for the Roo Code settings (thanks @KJ7LNW) +- Add `gemini-2.5-pro-preview-05-06` to the Vertex and Gemini providers (thanks @zetaloop) +- Ensure evals exercises are up-to-date before running evals (thanks @shariqriazz) +- Lots of general UI improvements (thanks @elianiva) +- Organize provider settings into separate components +- Improved icons and translations for the code block component +- Add support for tests that use ESM libraries +- Move environment detail generation to a separate module +- Enable prompt caching by default for supported Gemini models + +## [3.15.5] - 2025-05-05 + +- Update @google/genai to 0.12 (includes some streaming completion bug fixes) +- Rendering performance improvements for code blocks in chat (thanks @KJ7LNW) + +## [3.15.4] - 2025-05-04 + +- Fix a nasty bug that would cause Roo Code to hang, particularly in orchestrator mode +- Improve Gemini caching efficiency + +## [3.15.3] - 2025-05-02 + +- Terminal: Fix empty command bug +- Terminal: More robust process killing +- Optimize Gemini prompt caching for OpenRouter +- Chat view performance improvements + +## [3.15.2] - 2025-05-02 + +- Fix terminal performance issues +- Handle Mermaid validation errors +- Add customizable headers for OpenAI-compatible provider (thanks @mark-bradshaw!) +- Add config option to overwrite OpenAI's API base (thanks @GOODBOY008!) +- Fixes to padding and height issues when resizing the sidebar (thanks @zhangtony239!) +- Remove tool groups from orchestrator mode definition +- Add telemetry for title button clicks + +## [3.15.1] - 2025-04-30 + +- Capture stderr in execa-spawned processes +- Play sound only when action needed from the user (thanks @olearycrew) +- Make retries respect the global auto approve checkbox +- Fix a selection mode bug in the history view (thanks @jr) + +## [3.15.0] - 2025-04-30 + +- Add prompt caching to the Google Vertex provider (thanks @ashktn) +- Add a fallback mechanism for executing terminal commands if VSCode terminal shell integration fails +- Improve the UI/UX of code snippets in the chat (thanks @KJ7LNW) +- Add a reasoning effort setting for the OpenAI Compatible provider (thanks @mr-ryan-james) +- Allow terminal commands to be stopped directly from the chat UI +- Adjust chat view padding to accommodate small width layouts (thanks @zhangtony239) +- Fix file mentions for filenames containing spaces +- Improve the auto-approve toggle buttons for some high-contrast VSCode themes +- Offload expensive count token operations to a web worker (thanks @samhvw8) +- Improve support for multi-root workspaces (thanks @snoyiatk) +- Simplify and streamline Roo Code's quick actions +- Allow Roo Code settings to be imported from the welcome screen (thanks @julionav) +- Remove unused types (thanks @wkordalski) +- Improve the performance of mode switching (thanks @dlab-anton) +- Fix importing & exporting of custom modes (thanks @julionav) + +## [3.14.3] - 2025-04-25 + +- Add Boomerang Orchestrator as a built-in mode +- Improve home screen UI +- Make token count estimation more efficient to reduce gray screens +- Revert change to automatically close files after edit until we figure out how to make it work well with diagnostics +- Clean up settings data model +- Omit reasoning params for non-reasoning models +- Clearer documentation for adding settings (thanks @shariqriazz!) +- Fix word wrapping in Roo message title (thanks @zhangtony239!) +- Update default model id for Unbound from claude 3.5 to 3.7 (thanks @pugazhendhi-m!) + +## [3.14.2] - 2025-04-24 + +- Enable prompt caching for Gemini (with some improvements) +- Allow users to turn prompt caching on / off for Gemini 2.5 on OpenRouter +- Compress terminal output with backspace characters (thanks @KJ7LNW) +- Add Russian language (Спасибо @asychin) + +## [3.14.1] - 2025-04-24 + +- Disable Gemini caching while we investigate issues reported by the community. + +## [3.14.0] - 2025-04-23 + +- Add prompt caching for `gemini-2.5-pro-preview-03-25` in the Gemini provider (Vertex and OpenRouter coming soon!) +- Improve the search_and_replace and insert_content tools and bring them out of experimental, and deprecate append_to_file (thanks @samhvw8!) +- Use material icons for files and folders in mentions (thanks @elianiva!) +- Make the list_files tool more efficient and smarter about excluding directories like .git/ +- Fix file drag and drop on Windows and when using SSH tunnels (thanks @NyxJae!) +- Correctly revert changes and suggest alternative tools when write_to_file fails on a missing line count +- Allow interpolation of `workspace`, `mode`, `language`, `shell`, and `operatingSystem` into custom system prompt overrides (thanks @daniel-lxs!) +- Fix interpolation bug in the “add to context” code action (thanks @elianiva!) +- Preserve editor state and prevent tab unpinning during diffs (thanks @seedlord!) +- Improvements to icon rendering on Linux (thanks @elianiva!) +- Improvements to Requesty model list fetching (thanks @dtrugman!) +- Fix user feedback not being added to conversation history in API error state, redundant ‘TASK RESUMPTION’ prompts, and error messages not showing after cancelling API requests (thanks @System233!) +- Track tool use errors in evals +- Fix MCP hub error when dragging extension to another sidebar +- Improve display of long MCP tool arguments +- Fix redundant ‘TASK RESUMPTION’ prompts (thanks @System233!) +- Fix bug opening files when editor has no workspace root +- Make the VS Code LM provider show the correct model information (thanks @QuinsZouls!) +- Fixes to make the focusInput command more reliable (thanks @hongzio!) +- Better handling of aftercursor content in context mentions (thanks @elianiva!) +- Support injecting environment variables in MCP config (thanks @NamesMT!) +- Better handling of FakeAI “controller” object (thanks @wkordalski) +- Remove unnecessary calculation from VS Code LM provider (thanks @d-oit!) +- Allow Amazon Bedrock Marketplace ARNs (thanks @mlopezr!) +- Give better loading feedback on chat rows (thanks @elianiva!) +- Performance improvements to task size calculations +- Don’t immediately show a model ID error when changing API providers +- Fix apply_diff edge cases +- Use a more sensible task export icon +- Use path aliases in webview source files +- Display a warning when the system prompt is overridden +- Better progress indicator for apply_diff tools (thanks @qdaxb!) +- Fix terminal carriage return handling for correct progress bar display (thanks @Yikai-Liao!) + +## [3.13.2] - 2025-04-18 + +- Allow custom URLs for Gemini provider + +## [3.13.1] - 2025-04-18 + +- Support Gemini 2.5 Flash thinking mode (thanks @monotykamary) +- Make auto-approval toggle on/off states more obvious (thanks @sachasayan) +- Add telemetry for shell integration errors +- Fix the path of files dragging into the chat textarea on Windows (thanks @NyxJae) + +## [3.13.0] - 2025-04-17 + +- UI improvements to task header, chat view, history preview, and welcome view (thanks @sachasayan!) +- Add append_to_file tool for appending content to files (thanks @samhvw8!) +- Add Gemini 2.5 Flash Preview to Gemini and Vertex providers (thanks @nbihan-mediware!) +- Fix image support in Bedrock (thanks @Smartsheet-JB-Brown!) +- Make diff edits more resilient to models passing in incorrect parameters + +## [3.12.3] - 2025-04-17 + +- Fix character escaping issues in Gemini diff edits +- Support dragging and dropping tabs into the chat box (thanks @NyxJae!) +- Make sure slash commands only fire at the beginning of the chat box (thanks @logosstone!) + +## [3.12.2] - 2025-04-16 + +- Add OpenAI o3 & 4o-mini (thanks @PeterDaveHello!) +- Improve file/folder context mention UI (thanks @elianiva!) +- Improve diff error telemetry + +## [3.12.1] - 2025-04-16 + +- Bugfix to Edit button visibility in the select dropdowns + +## [3.12.0] - 2025-04-15 + +- Add xAI provider and expose reasoning effort options for Grok on OpenRouter (thanks Cline!) +- Make diff editing config per-profile and improve pre-diff string normalization +- Make checkpoints faster and more reliable +- Add a search bar to mode and profile select dropdowns (thanks @samhvw8!) +- Add telemetry for code action usage, prompt enhancement usage, and consecutive mistake errors +- Suppress zero cost values in the task header (thanks @do-it!) +- Make JSON parsing safer to avoid crashing the webview on bad input +- Allow users to bind a keyboard shortcut for accepting suggestions or input in the chat view (thanks @axkirillov!) + +## [3.11.17] - 2025-04-14 + +- Improvements to OpenAI cache reporting and cost estimates (thanks @monotykamary and Cline!) +- Visual improvements to the auto-approve toggles (thanks @sachasayan!) +- Bugfix to diff apply logic (thanks @avtc for the test case!) and telemetry to track errors going forward +- Fix race condition in capturing short-running terminal commands (thanks @KJ7LNW!) +- Fix eslint error (thanks @nobu007!) + +## [3.11.16] - 2025-04-14 + +- Add gpt-4.1, gpt-4.1-mini, and gpt-4.1-nano to the OpenAI provider +- Include model ID in environment details and when exporting tasks (thanks @feifei325!) + +## [3.11.15] - 2025-04-13 + +- Add ability to filter task history by workspace (thanks @samhvw8!) +- Fix Node.js version in the .tool-versions file (thanks @bogdan0083!) +- Fix duplicate suggested mentions for open tabs (thanks @samhvw8!) +- Fix Bedrock ARN validation and token expiry issue when using profiles (thanks @vagadiya!) +- Add Anthropic option to pass API token as Authorization header instead of X-Api-Key (thanks @mecab!) +- Better documentation for adding new settings (thanks @KJ7LNW!) +- Localize package.json (thanks @samhvw8!) +- Add option to hide the welcome message and fix the background color for the new profile dialog (thanks @zhangtony239!) +- Restore the focus ring for the VSCodeButton component (thanks @pokutuna!) + +## [3.11.14] - 2025-04-11 + +- Support symbolic links in rules folders to directories and other symbolic links (thanks @taisukeoe!) +- Stronger enforcement of the setting to always read full files instead of doing partial reads + +## [3.11.13] - 2025-04-11 + +- Loads of terminal improvements: command delay, PowerShell counter, and ZSH EOL mark (thanks @KJ7LNW!) +- Add file context tracking system (thanks @samhvw8 and @canvrno!) +- Improved display of diff errors + easy copying for investigation +- Fixes to .vscodeignore (thanks @franekp!) +- Fix a zh-CN translation for model capabilities (thanks @zhangtony239!) +- Rename Amazon Bedrock to Amazon Bedrock (thanks @ronyblum!) +- Update extension title and description (thanks @StevenTCramer!) + +## [3.11.12] - 2025-04-09 + +- Make Grok3 streaming work with OpenAI Compatible (thanks @amittell!) +- Tweak diff editing logic to make it more tolerant of model errors + +## [3.11.11] - 2025-04-09 + +- Fix highlighting interaction with mode/profile dropdowns (thanks @atlasgong!) +- Add the ability to set Host header and legacy OpenAI API in the OpenAI-compatible provider for better proxy support +- Improvements to TypeScript, C++, Go, Java, Python tree-sitter parsers (thanks @KJ7LNW!) +- Fixes to terminal working directory logic (thanks @KJ7LNW!) +- Improve readFileTool XML output format (thanks @KJ7LNW!) +- Add o1-pro support (thanks @arthurauffray!) +- Follow symlinked rules files/directories to allow for more flexible rule setups +- Focus Roo Code in the sidebar when running tasks in the sidebar via the API +- Improve subtasks UI + +## [3.11.10] - 2025-04-08 + +- Fix bug where nested .roo/rules directories are not respected properly (thanks @taisukeoe!) +- Handle long command output more efficiently in the chat row (thanks @samhvw8!) +- Fix cache usage tracking for OpenAI-compatible providers +- Add custom translation instructions for zh-CN (thanks @System233!) +- Code cleanup after making rate-limits per-profile (thanks @ross!) + +## [3.11.9] - 2025-04-07 + +- Rate-limit setting updated to be per-profile (thanks @ross and @olweraltuve!) +- You can now place multiple rules files in the .roo/rules/ and .roo/rules-{mode}/ folders (thanks @upamune!) +- Prevent unnecessary autoscroll when buttons appear (thanks @shtse8!) +- Add Gemini 2.5 Pro Preview to Vertex AI (thanks @nbihan-mediware!) +- Tidy up following ClineProvider refactor (thanks @diarmidmackenzie!) +- Clamp negative line numbers when reading files (thanks @KJ7LNW!) +- Enhance Rust tree-sitter parser with advanced language structures (thanks @KJ7LNW!) +- Persist settings on api.setConfiguration (thanks @gtaylor!) +- Add deep links to settings sections +- Add command to focus Roo Code input field (thanks @axkirillov!) +- Add resize and hover actions to the browser (thanks @SplittyDev!) +- Add resumeTask and isTaskInHistory to the API (thanks @franekp!) +- Fix bug displaying boolean/numeric suggested answers +- Dynamic Vite port detection for webview development (thanks @KJ7LNW!) + +## [3.11.8] - 2025-04-05 + +- Improve combineApiRequests performance to reduce gray screens of death (thanks @kyle-apex!) +- Add searchable dropdown to API config profiles on the settings screen (thanks @samhvw8!) +- Add workspace tracking to history items in preparation for future filtering (thanks @samhvw8!) +- Fix search highlighting UI in history search (thanks @samhvw8!) +- Add support for .roorules and give deprecation warning for .clinerules (thanks @upamune!) +- Fix nodejs version format in .tool-versions file (thanks @upamune!) + +## [3.11.7] - 2025-04-04 + +- Improve file tool context formatting and diff error guidance +- Improve zh-TW localization (thanks @PeterDaveHello!) +- Implement reference counting for McpHub disposal +- Update buttons to be more consistent (thanks @kyle-apex!) +- Improve zh-CN localization (thanks @System233!) + +## [3.11.6] - 2025-04-04 + +- Add the gemini 2.5 pro preview model with upper bound pricing + +## [3.11.5] - 2025-04-03 + +- Add prompt caching for Amazon Bedrock (thanks @Smartsheet-JB-Brown!) +- Add support for configuring the current working directory of MCP servers (thanks @shoopapa!) +- Add profile management functions to API (thanks @gtaylor!) +- Improvements to diff editing functionality, tests, and error messages (thanks @p12tic!) +- Fix for follow-up questions grabbing the focus (thanks @diarmidmackenzie!) +- Show menu buttons when popping the extension out into a new tab (thanks @benny123tw!) + +## [3.11.4] - 2025-04-02 + +- Correctly post state to webview when the current task is cleared (thanks @wkordalski!) +- Fix unit tests to run properly on Windows (thanks @StevenTCramer!) +- Tree-sitter enhancements: TSX, TypeScript, JSON, and Markdown support (thanks @KJ7LNW!) +- Fix issue with line number stripping for deletions in apply_diff +- Update history selection mode button spacing (thanks @kyle-apex!) +- Limit dropdown menu height to 80% of the viewport (thanks @axmo!) +- Update dependencies via `npm audit fix` (thanks @PeterDaveHello!) +- Enable model select when api fails (thanks @kyle-apex!) +- Fix issue where prompts and settings tabs were not scrollable when accessed from dropdown menus +- Update AWS region dropdown menu to the most recent data (thanks @Smartsheet-JB-Brown!) +- Fix prompt enhancement for Bedrock (thanks @Smartsheet-JB-Brown!) +- Allow processes to access the Roo Code API via a unix socket +- Improve zh-TW Traditional Chinese translations (thanks @PeterDaveHello!) +- Add support for Azure AI Inference Service with DeepSeek-V3 model (thanks @thomasjeung!) +- Fix off-by-one error in tree-sitter line numbers +- Remove the experimental unified diff +- Make extension icon more visible in different themes + +## [3.11.3] - 2025-03-31 + +- Revert mention changes in case they're causing performance issues/crashes + +## [3.11.2] - 2025-03-31 + +- Fix bug in loading Requesty key balance +- Fix bug with Bedrock inference profiles +- Update the webview when changing settings via the API +- Refactor webview messages code (thanks @diarmidmackenzie!) + +## [3.11.1] - 2025-03-30 + +- Relax provider profiles schema and add telemetry + +## [3.11.0] - 2025-03-30 + +- Replace single-block-diff with multi-block-diff fast editing strategy +- Support project-level MCP config in .roo/mcp.json (thanks @aheizi!) +- Show OpenRouter and Requesty key balance on the settings screen +- Support import/export of settings +- Add pinning and sorting for API configuration dropdown (thanks @jwcraig!) +- Add Gemini 2.5 Pro to GCP Vertex AI provider (thanks @nbihan-mediware!) +- Smarter retry logic for Gemini +- Fix Gemini command escaping +- Support @-mentions of files with spaces in the name (thanks @samhvw8!) +- Improvements to partial file reads (thanks @KJ7LNW!) +- Fix list_code_definition_names to support files (thanks @KJ7LNW!) +- Refactor tool-calling logic to make the code a lot easier to work with (thanks @diarmidmackenzie, @bramburn, @KJ7LNW, and everyone else who helped!) +- Prioritize “Add to Context” in the code actions and include line numbers (thanks @samhvw8!) +- Add an activation command that other extensions can use to interface with Roo Code (thanks @gtaylor!) +- Preserve language characters in file @-mentions (thanks @aheizi!) +- Browser tool improvements (thanks @afshawnlotfi!) +- Display info about partial reads in the chat row +- Link to the settings page from the auto-approve toolbar +- Link to provider docs from the API options +- Fix switching profiles to ensure only the selected profile is switched (thanks @feifei325!) +- Allow custom o3-mini- model from OpenAI-compatible providers (thanks @snoyiatk!) +- Edit suggested answers before accepting them (thanks @samhvw8!) + +## [3.10.5] - 2025-03-25 + +- Updated value of max tokens for gemini-2.5-pro-03-25 to 65,536 (thanks @linegel!) +- Fix logic around when we fire task completion events + +## [3.10.4] - 2025-03-25 + +- Dynamically fetch instructions for creating/editing custom modes and MCP servers (thanks @diarmidmackenzie!) +- Added Gemini 2.5 Pro model to Google Gemini provider (thanks @samsilveira!) +- Add settings to control whether to auto-approve reads and writes outside of the workspace +- Update UX for chat text area (thanks @chadgauth!) +- Support a custom storage path for tasks (thanks @Chenjiayuan195!) +- Add a New Task command in the Command Palette (thanks @qdaxb!) +- Add R1 support checkbox to Open AI compatible provider to support QWQ (thanks @teddyOOXX!) +- Support test declarations in TypeScript tree-sitter queries (thanks @KJ7LNW!) +- Add Bedrock support for application-inference-profile (thanks @maekawataiki!) +- Rename and migrate global MCP and modes files (thanks @StevenTCramer!) +- Add watchPaths option to McpHub for file change detection (thanks @01Rian!) +- Read image responses from MCP calls (thanks @nevermorec!) +- Add taskCreated event to API and subscribe to Cline events earlier (thanks @wkordalski!) +- Fixes to numeric formatting suffix internationalization (thanks @feifei325!) +- Fix open tab support in the context mention suggestions (thanks @aheizi!) +- Better display of OpenRouter “overloaded” error messages +- Fix browser tool visibility in system prompt preview (thanks @cannuri!) +- Fix the supportsPromptCache value for OpenAI models (thanks @PeterDaveHello!) +- Fix readme links to docs (thanks @kvokka!) +- Run ‘npm audit fix’ on all of our libraries + +## [3.10.3] - 2025-03-23 + +- Update the welcome page to provide 1-click OAuth flows with LLM routers (thanks @dtrugman!) +- Switch to a more direct method of tracking OpenRouter tokens/spend +- Make partial file reads backwards-compatible with custom system prompts and give users more control over the chunk size +- Fix issues where questions and suggestions weren’t showing up for non-streaming models and were hard to read in some themes +- A variety of fixes and improvements to experimental multi-block diff (thanks @KJ7LNW!) +- Fix opacity of drop-down menus in settings (thanks @KJ7LNW!) +- Fix bugs with reading and mentioning binary files like PDFs +- Fix the pricing information for OpenRouter free models (thanks @Jdo300!) +- Fix an issue with our unit tests on Windows (thanks @diarmidmackenzie!) +- Fix a maxTokens issue for the Outbound provider (thanks @pugazhendhi-m!) +- Fix a line number issue with partial file reads (thanks @samhvw8!) + +## [3.10.2] - 2025-03-21 + +- Fixes to context mentions on Windows +- Fixes to German translations (thanks @cannuri!) +- Fixes to telemetry banner internationalization +- Sonnet 3.7 non-thinking now correctly uses 8192 max output tokens + +## [3.10.1] - 2025-03-20 + +- Make the suggested responses optional to not break overridden system prompts + +## [3.10.0] - 2025-03-20 + +- Suggested responses to questions (thanks samhvw8!) +- Support for reading large files in chunks (thanks samhvw8!) +- More consistent @-mention lookups of files and folders +- Consolidate code actions into a submenu (thanks samhvw8!) +- Fix MCP error logging (thanks aheizi!) +- Improvements to search_files tool formatting and logic (thanks KJ7LNW!) +- Fix changelog formatting in GitHub Releases (thanks pdecat!) +- Add fake provider for integration tests (thanks franekp!) +- Reflect Cross-region inference option in ap-xx region (thanks Yoshino-Yukitaro!) +- Fix bug that was causing task history to be lost when using WSL + +## [3.9.2] - 2025-03-19 + +- Update GitHub Actions workflow to automatically create GitHub Releases (thanks @pdecat!) +- Correctly persist the text-to-speech speed state (thanks @heyseth!) +- Fixes to French translations (thanks @arthurauffray!) +- Optimize build time for local development (thanks @KJ7LNW!) +- VSCode theme fixes for select, dropdown and command components +- Bring back the ability to manually enter a model name in the model picker +- Fix internationalization of the announcement title and the browser + +## [3.9.1] - 2025-03-18 + +- Pass current language to system prompt correctly so Roo thinks and speaks in the selected language + +## [3.9.0] - 2025-03-18 + +- Internationalize Roo Code into Catalan, German, Spanish, French, Hindi, Italian, Japanese, Korean, Polish, Portuguese, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese (thanks @feifei325!) +- Bring back support for MCP over SSE (thanks @aheizi!) +- Add a text-to-speech option to have Roo talk to you as it works (thanks @heyseth!) +- Choose a specific provider when using OpenRouter (thanks PhunkyBob!) +- Support batch deletion of task history (thanks @aheizi!) +- Internationalize Human Relay, adjust the layout, and make it work on the welcome screen (thanks @NyxJae!) +- Fix shell integration race condition (thanks @KJ7LNW!) +- Fix display updating for Bedrock custom ARNs that are prompt routers (thanks @Smartsheet-JB-Brown!) +- Fix to exclude search highlighting when copying items from task history (thanks @im47cn!) +- Fix context mentions to work with multiple-workspace projects (thanks @teddyOOXX!) +- Fix to task history saving when running multiple Roos (thanks @samhvw8!) +- Improve task deletion when underlying files are missing (thanks @GitlyHallows!) +- Improve support for NixOS & direnv (thanks @wkordalski!) +- Fix wheel scrolling when Roo is opened in editor tabs (thanks @GitlyHallows!) +- Don’t automatically mention the file when using the "Add to context" code action (thanks @qdaxb!) +- Expose task stack in `RooCodeAPI` (thanks @franekp!) +- Give the models visibility into the current task's API cost + +## [3.8.6] - 2025-03-13 + +- Revert SSE MCP support while we debug some config validation issues + +## [3.8.5] - 2025-03-12 + +- Refactor terminal architecture to address critical issues with the current design (thanks @KJ7LNW!) +- MCP over SSE (thanks @aheizi!) +- Support for remote browser connections (thanks @afshawnlotfi!) +- Preserve parent-child relationship when cancelling subtasks (thanks @cannuri!) +- Custom baseUrl for Google AI Studio Gemini (thanks @dqroid!) +- PowerShell-specific command handling (thanks @KJ7LNW!) +- OpenAI-compatible DeepSeek/QwQ reasoning support (thanks @lightrabbit!) +- Anthropic-style prompt caching in the OpenAI-compatible provider (thanks @dleen!) +- Add Deepseek R1 for Amazon Bedrock (thanks @ATempsch!) +- Fix MarkdownBlock text color for Dark High Contrast theme (thanks @cannuri!) +- Add gemini-2.0-pro-exp-02-05 model to vertex (thanks @shohei-ihaya!) +- Bring back progress status for multi-diff edits (thanks @qdaxb!) +- Refactor alert dialog styles to use the correct vscode theme (thanks @cannuri!) +- Custom ARNs in Amazon Bedrock (thanks @Smartsheet-JB-Brown!) +- Update MCP servers directory path for platform compatibility (thanks @hannesrudolph!) +- Fix browser system prompt inclusion rules (thanks @cannuri!) +- Publish git tags to GitHub from CI (thanks @pdecat!) +- Fixes to OpenAI-style cost calculations (thanks @dtrugman!) +- Fix to allow using an excluded directory as your working directory (thanks @Szpadel!) +- Kotlin language support in list_code_definition_names tool (thanks @kohii!) +- Better handling of diff application errors (thanks @qdaxb!) +- Update Bedrock prices to the latest (thanks @Smartsheet-JB-Brown!) +- Fixes to OpenRouter custom baseUrl support +- Fix usage tracking for SiliconFlow and other providers that include usage on every chunk +- Telemetry for checkpoint save/restore/diff and diff strategies + +## [3.8.4] - 2025-03-09 + +- Roll back multi-diff progress indicator temporarily to fix a double-confirmation in saving edits +- Add an option in the prompts tab to save tokens by disabling the ability to ask Roo to create/edit custom modes for you (thanks @hannesrudolph!) + +## [3.8.3] - 2025-03-09 + +- Fix VS Code LM API model picker truncation issue + +## [3.8.2] - 2025-03-08 + +- Create an auto-approval toggle for subtask creation and completion (thanks @shaybc!) +- Show a progress indicator when using the multi-diff editing strategy (thanks @qdaxb!) +- Add o3-mini support to the OpenAI-compatible provider (thanks @yt3trees!) +- Fix encoding issue where unreadable characters were sometimes getting added to the beginning of files +- Fix issue where settings dropdowns were getting truncated in some cases + +## [3.8.1] - 2025-03-07 + +- Show the reserved output tokens in the context window visualization +- Improve the UI of the configuration profile dropdown (thanks @DeXtroTip!) +- Fix bug where custom temperature could not be unchecked (thanks @System233!) +- Fix bug where decimal prices could not be entered for OpenAI-compatible providers (thanks @System233!) +- Fix bug with enhance prompt on Sonnet 3.7 with a high thinking budget (thanks @moqimoqidea!) +- Fix bug with the context window management for thinking models (thanks @ReadyPlayerEmma!) +- Fix bug where checkpoints were no longer enabled by default +- Add extension and VSCode versions to telemetry + +## [3.8.0] - 2025-03-07 + +- Add opt-in telemetry to help us improve Roo Code faster (thanks Cline!) +- Fix terminal overload / gray screen of death, and other terminal issues +- Add a new experimental diff editing strategy that applies multiple diff edits at once (thanks @qdaxb!) +- Add support for a .rooignore to prevent Roo Code from read/writing certain files, with a setting to also exclude them from search/lists (thanks Cline!) +- Update the new_task tool to return results to the parent task on completion, supporting better orchestration (thanks @shaybc!) +- Support running Roo in multiple editor windows simultaneously (thanks @samhvw8!) +- Make checkpoints asynchronous and exclude more files to speed them up +- Redesign the settings page to make it easier to navigate +- Add credential-based authentication for Vertex AI, enabling users to easily switch between Google Cloud accounts (thanks @eonghk!) +- Update the DeepSeek provider with the correct baseUrl and track caching correctly (thanks @olweraltuve!) +- Add a new “Human Relay” provider that allows you to manually copy information to a Web AI when needed, and then paste the AI's response back into Roo Code (thanks @NyxJae)! +- Add observability for OpenAI providers (thanks @refactorthis!) +- Support speculative decoding for LM Studio local models (thanks @adamwlarson!) +- Improve UI for mode/provider selectors in chat +- Improve styling of the task headers (thanks @monotykamary!) +- Improve context mention path handling on Windows (thanks @samhvw8!) + +## [3.7.12] - 2025-03-03 + +- Expand max tokens of thinking models to 128k, and max thinking budget to over 100k (thanks @monotykamary!) +- Fix issue where keyboard mode switcher wasn't updating API profile (thanks @aheizi!) +- Use the count_tokens API in the Anthropic provider for more accurate context window management +- Default middle-out compression to on for OpenRouter +- Exclude MCP instructions from the prompt if the mode doesn't support MCP +- Add a checkbox to disable the browser tool +- Show a warning if checkpoints are taking too long to load +- Update the warning text for the VS LM API +- Correctly populate the default OpenRouter model on the welcome screen + +## [3.7.11] - 2025-03-02 + +- Don't honor custom max tokens for non thinking models +- Include custom modes in mode switching keyboard shortcut +- Support read-only modes that can run commands + +## [3.7.10] - 2025-03-01 + +- Add Gemini models on Vertex AI (thanks @ashktn!) +- Keyboard shortcuts to switch modes (thanks @aheizi!) +- Add support for Mermaid diagrams (thanks Cline!) + +## [3.7.9] - 2025-03-01 + +- Delete task confirmation enhancements +- Smarter context window management +- Prettier thinking blocks +- Fix maxTokens defaults for Claude 3.7 Sonnet models +- Terminal output parsing improvements (thanks @KJ7LNW!) +- UI fix to dropdown hover colors (thanks @SamirSaji!) +- Add support for Claude Sonnet 3.7 thinking via Vertex AI (thanks @lupuletic!) + +## [3.7.8] - 2025-02-27 + +- Add Vertex AI prompt caching support for Claude models (thanks @aitoroses and @lupuletic!) +- Add gpt-4.5-preview +- Add an advanced feature to customize the system prompt + +## [3.7.7] - 2025-02-27 + +- Graduate checkpoints out of beta +- Fix enhance prompt button when using Thinking Sonnet +- Add tooltips to make what buttons do more obvious + +## [3.7.6] - 2025-02-26 + +- Handle really long text better in the ChatRow similar to TaskHeader (thanks @joemanley201!) +- Support multiple files in drag-and-drop +- Truncate search_file output to avoid crashing the extension +- Better OpenRouter error handling (no more "Provider Error") +- Add slider to control max output tokens for thinking models + +## [3.7.5] - 2025-02-26 + +- Fix context window truncation math (see [#1173](https://github.com/RooCodeInc/Roo-Code/issues/1173)) +- Fix various issues with the model picker (thanks @System233!) +- Fix model input / output cost parsing (thanks @System233!) +- Add drag-and-drop for files +- Enable the "Thinking Budget" slider for Claude 3.7 Sonnet on OpenRouter + +## [3.7.4] - 2025-02-25 + +- Fix a bug that prevented the "Thinking" setting from properly updating when switching profiles. + +## [3.7.3] - 2025-02-25 + +- Support for ["Thinking"](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) Sonnet 3.7 when using the Anthropic provider. + +## [3.7.2] - 2025-02-24 + +- Fix computer use and prompt caching for OpenRouter's `anthropic/claude-3.7-sonnet:beta` (thanks @cte!) +- Fix sliding window calculations for Sonnet 3.7 that were causing a context window overflow (thanks @cte!) +- Encourage diff editing more strongly in the system prompt (thanks @hannesrudolph!) + +## [3.7.1] - 2025-02-24 + +- Add Amazon Bedrock support for Sonnet 3.7 and update some defaults to Sonnet 3.7 instead of 3.5 + +## [3.7.0] - 2025-02-24 + +- Introducing Roo Code 3.7, with support for the new Claude Sonnet 3.7. Because who cares about skipping version numbers anymore? Thanks @lupuletic and @cte for the PRs! + +## [3.3.26] - 2025-02-27 + +- Adjust the default prompt for Debug mode to focus more on diagnosis and to require user confirmation before moving on to implementation + +## [3.3.25] - 2025-02-21 + +- Add a "Debug" mode that specializes in debugging tricky problems (thanks [Ted Werbel](https://x.com/tedx_ai/status/1891514191179309457) and [Carlos E. Perez](https://x.com/IntuitMachine/status/1891516362486337739)!) +- Add an experimental "Power Steering" option to significantly improve adherence to role definitions and custom instructions + +## [3.3.24] - 2025-02-20 + +- Fixed a bug with region selection preventing Amazon Bedrock profiles from being saved (thanks @oprstchn!) +- Updated the price of gpt-4o (thanks @marvijo-code!) + +## [3.3.23] - 2025-02-20 + +- Handle errors more gracefully when reading custom instructions from files (thanks @joemanley201!) +- Bug fix to hitting "Done" on settings page with unsaved changes (thanks @System233!) + +## [3.3.22] - 2025-02-20 + +- Improve the Provider Settings configuration with clear Save buttons and warnings about unsaved changes (thanks @System233!) +- Correctly parse `` reasoning tags from Ollama models (thanks @System233!) +- Add support for setting custom preferred languages on the Prompts tab, as well as adding Catalan to the list of languages (thanks @alarno!) +- Add a button to delete MCP servers (thanks @hannesrudolph!) +- Fix a bug where the button to copy the system prompt preview always copied the Code mode version +- Fix a bug where the .roomodes file was not automatically created when adding custom modes from the Prompts tab +- Allow setting a wildcard (`*`) to auto-approve all command execution (use with caution!) + +## [3.3.21] - 2025-02-17 + +- Fix input box revert issue and configuration loss during profile switch (thanks @System233!) +- Fix default preferred language for zh-cn and zh-tw (thanks @System233!) +- Fix Mistral integration (thanks @d-oit!) +- Feature to mention `@terminal` to pull terminal output into context (thanks Cline!) +- Fix system prompt to make sure Roo knows about all available modes +- Enable streaming mode for OpenAI o1 + +## [3.3.20] - 2025-02-14 + +- Support project-specific custom modes in a .roomodes file +- Add more Mistral models (thanks @d-oit and @bramburn!) +- By popular request, make it so Ask mode can't write to Markdown files and is purely for chatting with +- Add a setting to control the number of open editor tabs to tell the model about (665 is probably too many!) +- Fix race condition bug with entering API key on the welcome screen + +## [3.3.19] - 2025-02-12 + +- Fix a bug where aborting in the middle of file writes would not revert the write +- Honor the VS Code theme for dialog backgrounds +- Make it possible to clear out the default custom instructions for built-in modes +- Add a help button that links to our new documentation site (which we would love help from the community to improve!) +- Switch checkpoints logic to use a shadow git repository to work around issues with hot reloads and polluting existing repositories (thanks Cline for the inspiration!) + +## [3.3.18] - 2025-02-11 + +- Add a per-API-configuration model temperature setting (thanks @joemanley201!) +- Add retries for fetching usage stats from OpenRouter (thanks @jcbdev!) +- Fix bug where disabled MCP servers would not show up in the settings on initialization (thanks @MuriloFP!) +- Add the Requesty provider and clean up a lot of shared model picker code (thanks @samhvw8!) +- Add a button on the Prompts tab to copy the full system prompt to the clipboard (thanks @mamertofabian!) +- Fix issue where Ollama/LMStudio URLs would flicker back to previous while entering them in settings +- Fix logic error where automatic retries were waiting twice as long as intended +- Rework the checkpoints code to avoid conflicts with file locks on Windows (sorry for the hassle!) + +## [3.3.17] - 2025-02-09 + +- Fix the restore checkpoint popover +- Unset git config that was previously set incorrectly by the checkpoints feature + +## [3.3.16] - 2025-02-09 + +- Support Volcano Ark platform through the OpenAI-compatible provider +- Fix jumpiness while entering API config by updating on blur instead of input +- Add tooltips on checkpoint actions and fix an issue where checkpoints were overwriting existing git name/email settings - thanks for the feedback! + +## [3.3.15] - 2025-02-08 + +- Improvements to MCP initialization and server restarts (thanks @MuriloFP and @hannesrudolph!) +- Add a copy button to the recent tasks (thanks @hannesrudolph!) +- Improve the user experience for adding a new API profile +- Another significant fix to API profile switching on the settings screen +- Opt-in experimental version of checkpoints in the advanced settings + +## [3.3.14] + +- Should have skipped floor 13 like an elevator. This fixes the broken 3.3.13 release by reverting some changes to the deployment scripts. + +## [3.3.13] + +- Ensure the DeepSeek r1 model works with Ollama (thanks @sammcj!) +- Enable context menu commands in the terminal (thanks @samhvw8!) +- Improve sliding window truncation strategy for models that do not support prompt caching (thanks @nissa-seru!) +- First step of a more fundamental fix to the bugs around switching API profiles. If you've been having issues with this please try again and let us know if works any better! More to come soon, including fixing the laggy text entry in provider settings. + +## [3.3.12] + +- Bug fix to changing a mode's API configuration on the prompts tab +- Add new Gemini models + +## [3.3.11] + +- Safer shell profile path check to avoid an error on Windows +- Autocomplete for slash commands + +## [3.3.10] + +- Add shortcuts to the currently open tabs in the "Add File" section of @-mentions (thanks @olup!) +- Fix pricing for o1-mini (thanks @hesara!) +- Fix context window size calculation (thanks @MuriloFP!) +- Improvements to experimental unified diff strategy and selection logic in code actions (thanks @nissa-seru!) +- Enable markdown formatting in o3 and o1 (thanks @nissa-seru!) +- Improved terminal shell detection logic (thanks @canvrno for the original and @nissa-seru for the port!) +- Fix occasional errors when switching between API profiles (thanks @samhvw8!) +- Visual improvements to the list of modes on the prompts tab +- Fix double-scrollbar in provider dropdown +- Visual cleanup to the list of modes on the prompts tab +- Improvements to the default prompts for Architect and Ask mode +- Allow switching between modes with slash messages like `/ask why is the sky blue?` + +## [3.3.9] + +- Add o3-mini-high and o3-mini-low + +## [3.3.8] + +- Fix o3-mini in the Glama provider (thanks @Punkpeye!) +- Add the option to omit instructions for creating MCP servers from the system prompt (thanks @samhvw8!) +- Fix a bug where renaming API profiles without actually changing the name would delete them (thanks @samhvw8!) + +## [3.3.7] + +- Support for o3-mini (thanks @shpigunov!) +- Code Action improvements to allow selecting code and adding it to context, plus bug fixes (thanks @samhvw8!) +- Ability to include a message when approving or rejecting tool use (thanks @napter!) +- Improvements to chat input box styling (thanks @psv2522!) +- Capture reasoning from more variants of DeepSeek R1 (thanks @Szpadel!) +- Use an exponential backoff for API retries (if delay after first error is 5s, delay after second consecutive error will be 10s, then 20s, etc) +- Add a slider in advanced settings to enable rate limiting requests to avoid overloading providers (i.e. wait at least 10 seconds between API requests) +- Prompt tweaks to make Roo better at creating new custom modes for you + +## [3.3.6] + +- Add a "new task" tool that allows Roo to start new tasks with an initial message and mode +- Fix a bug that was preventing the use of qwen-max and potentially other OpenAI-compatible providers (thanks @Szpadel!) +- Add support for perplexity/sonar-reasoning (thanks @Szpadel!) +- Visual fixes to dropdowns (thanks @psv2522!) +- Add the [Unbound](https://getunbound.ai/) provider (thanks @vigneshsubbiah16!) + +## [3.3.5] + +- Make information about the conversation's context window usage visible in the task header for humans and in the environment for models (thanks @MuriloFP!) +- Add checkboxes to auto-approve mode switch requests (thanks @MuriloFP!) +- Add new experimental editing tools `insert_content` (for inserting blocks of text at a line number) and `search_and_replace` (for replacing all instances of a phrase or regex) to complement diff editing and whole file editing (thanks @samhvw8!) +- Improved DeepSeek R1 support by capturing reasoning from DeepSeek API as well as more OpenRouter variants, not using system messages, and fixing a crash on empty chunks. Still depends on the DeepSeek API staying up but we'll be in a better place when it does! (thanks @Szpadel!) + +## [3.3.4] + +- Add per-server MCP network timeout configuration ranging from 15 seconds to an hour +- Speed up diff editing (thanks @hannesrudolph and @KyleHerndon!) +- Add option to perform explain/improve/fix code actions either in the existing task or a new task (thanks @samhvw8!) + +## [3.3.3] + +- Throw errors sooner when a mode tries to write a restricted file +- Styling improvements to the mode/configuration dropdowns (thanks @psv2522!) + +## [3.3.2] + +- Add a dropdown to select the API configuration for a mode in the Prompts tab +- Fix bug where always allow wasn't showing up for MCP tools +- Improve OpenRouter DeepSeek-R1 integration by setting temperature to the recommended 0.6 and displaying the reasoning output (thanks @Szpadel - it's really fascinating to watch!) +- Allow specifying a custom OpenRouter base URL (thanks @dairui1!) +- Make the UI for nested settings nicer (thanks @PretzelVector!) + +## [3.3.1] + +- Fix issue where the terminal management system was creating unnecessary new terminals (thanks @evan-fannin!) +- Fix bug where the saved API provider for a mode wasn't being selected after a mode switch command + +## [3.3.0] + +- Native VS Code code actions support with quick fixes and refactoring options +- Modes can now request to switch to other modes when needed +- Ask and Architect modes can now edit markdown files +- Custom modes can now be restricted to specific file patterns (for example, a technical writer who can only edit markdown files 👋) +- Support for configuring the Bedrock provider with AWS Profiles +- New Roo Code community Discord at https://roocode.com/discord! + +## [3.2.8] + +- Fixed bug opening custom modes settings JSON +- Reverts provider key entry back to checking onInput instead of onChange to hopefully address issues entering API keys (thanks @samhvw8!) +- Added explicit checkbox to use Azure for OpenAI compatible providers (thanks @samhvw8!) +- Fixed Glama usage reporting (thanks @punkpeye!) +- Added Llama 3.3 70B Instruct model to the Amazon Bedrock provider options (thanks @Premshay!) + +## [3.2.7] + +- Fix bug creating new configuration profiles + +## [3.2.6] + +- Fix bug with role definition overrides for built-in modes + +## [3.2.5] + +- Added gemini flash thinking 01-21 model and a few visual fixes (thanks @monotykamary!) + +## [3.2.4] + +- Only allow use of the diff tool if it's enabled in settings + +## [3.2.3] + +- Fix bug where language selector wasn't working + +## [3.2.0 - 3.2.2] + +- **Name Change From Roo Cline to Roo Code:** We're excited to announce our new name! After growing beyond 50,000 installations, we've rebranded from Roo Cline to Roo Code to better reflect our identity as we chart our own course. + +- **Custom Modes:** Create your own personas for Roo Code! While our built-in modes (Code, Architect, Ask) are still here, you can now shape entirely new ones: + - Define custom prompts + - Choose which tools each mode can access + - Create specialized assistants for any workflow + - Just type "Create a new mode for " or visit the Prompts tab in the top menu to get started + +Join us at https://www.reddit.com/r/RooCode to share your custom modes and be part of our next chapter! + +## [3.1.7] + +- DeepSeek-R1 support (thanks @philipnext!) +- Experimental new unified diff algorithm can be enabled in settings (thanks @daniel-lxs!) +- More fixes to configuration profiles (thanks @samhvw8!) + +## [3.1.6] + +- Add Mistral (thanks Cline!) +- Fix bug with VSCode LM configuration profile saving (thanks @samhvw8!) + +## [3.1.4 - 3.1.5] + +- Bug fixes to the auto approve menu + +## [3.1.3] + +- Add auto-approve chat bar (thanks Cline!) +- Fix bug with VS Code Language Models integration + +## [3.1.2] + +- Experimental support for VS Code Language Models including Copilot (thanks @RaySinner / @julesmons!) +- Fix bug related to configuration profile switching (thanks @samhvw8!) +- Improvements to fuzzy search in mentions, history, and model lists (thanks @samhvw8!) +- PKCE support for Glama (thanks @punkpeye!) +- Use 'developer' message for o1 system prompt + +## [3.1.1] + +- Visual fixes to chat input and settings for the light+ themes + +## [3.1.0] + +- You can now customize the role definition and instructions for each chat mode (Code, Architect, and Ask), either through the new Prompts tab in the top menu or mode-specific .clinerules-mode files. Prompt Enhancements have also been revamped: the "Enhance Prompt" button now works with any provider and API configuration, giving you the ability to craft messages with fully customizable prompts for even better results. +- Add a button to copy markdown out of the chat + +## [3.0.3] + +- Update required vscode engine to ^1.84.0 to match cline + +## [3.0.2] + +- A couple more tiny tweaks to the button alignment in the chat input + +## [3.0.1] + +- Fix the reddit link and a small visual glitch in the chat input + +## [3.0.0] + +- This release adds chat modes! Now you can ask Roo Code questions about system architecture or the codebase without immediately jumping into writing code. You can even assign different API configuration profiles to each mode if you prefer to use different models for thinking vs coding. Would love feedback in the new Roo Code Reddit! https://www.reddit.com/r/RooCode + +## [2.2.46] + +- Only parse @-mentions in user input (not in files) + +## [2.2.45] + +- Save different API configurations to quickly switch between providers and settings (thanks @samhvw8!) + +## [2.2.44] + +- Automatically retry failed API requests with a configurable delay (thanks @RaySinner!) + +## [2.2.43] + +- Allow deleting single messages or all subsequent messages + +## [2.2.42] + +- Add a Git section to the context mentions + +## [2.2.41] + +- Checkbox to disable streaming for OpenAI-compatible providers + +## [2.2.40] + +- Add the Glama provider (thanks @punkpeye!) + +## [2.2.39] + +- Add toggle to enable/disable the MCP-related sections of the system prompt (thanks @daniel-lxs!) + +## [2.2.38] + +- Add a setting to control the number of terminal output lines to pass to the model when executing commands + +## [2.2.36 - 2.2.37] + +- Add a button to delete user messages + +## [2.2.35] + +- Allow selection of multiple browser viewport sizes and adjusting screenshot quality + +## [2.2.34] + +- Add the DeepSeek provider + +## [2.2.33] + +- "Enhance prompt" button (OpenRouter models only for now) +- Support listing models for OpenAI compatible providers (thanks @samhvw8!) + +## [2.2.32] + +- More efficient workspace tracker + +## [2.2.31] + +- Improved logic for auto-approving chained commands + +## [2.2.30] + +- Fix bug with auto-approving commands + +## [2.2.29] + +- Add configurable delay after auto-writes to allow diagnostics to catch up + +## [2.2.28] + +- Use createFileSystemWatcher to more reliably update list of files to @-mention + +## [2.2.27] + +- Add the current time to the system prompt and improve browser screenshot quality (thanks @libertyteeth!) + +## [2.2.26] + +- Tweaks to preferred language (thanks @yongjer) + +## [2.2.25] + +- Add a preferred language dropdown + +## [2.2.24] + +- Default diff editing to on for new installs + +## [2.2.23] + +- Fix context window for gemini-2.0-flash-thinking-exp-1219 (thanks @student20880) + +## [2.2.22] + +- Add gemini-2.0-flash-thinking-exp-1219 + +## [2.2.21] + +- Take predicted file length into account when detecting omissions + +## [2.2.20] + +- Make fuzzy diff matching configurable (and default to off) + +## [2.2.19] + +- Add experimental option to use a bigger browser (1280x800) + +## [2.2.18] + +- More targeted styling fix for Gemini chats + +## [2.2.17] + +- Improved regex for auto-execution of chained commands + +## [2.2.16] + +- Incorporate Premshay's [PR](https://github.com/RooCodeInc/Roo-Code/pull/60) to add support for Amazon Nova and Meta Llama Models via Bedrock (3, 3.1, 3.2) and unified Bedrock calls using BedrockClient and Bedrock Runtime API + +## [2.2.14 - 2.2.15] + +- Make diff editing more robust to transient errors / fix bugs + +## [2.2.13] + +- Fixes to sound playing and applying diffs + +## [2.2.12] + +- Better support for pure deletion and insertion diffs + +## [2.2.11] + +- Added settings checkbox for verbose diff debugging + +## [2.2.6 - 2.2.10] + +- More fixes to search/replace diffs + +## [2.2.5] + +- Allow MCP servers to be enabled/disabled + +## [2.2.4] + +- Tweak the prompt to encourage diff edits when they're enabled + +## [2.2.3] + +- Clean up the settings screen + +## [2.2.2] + +- Add checkboxes to auto-approve MCP tools + +## [2.2.1] + +- Fix another diff editing indentation bug + +## [2.2.0] + +- Incorporate MCP changes from Cline 2.2.0 + +## [2.1.21] + +- Larger text area input + ability to drag images into it + +## [2.1.20] + +- Add Gemini 2.0 + +## [2.1.19] + +- Better error handling for diff editing + +## [2.1.18] + +- Diff editing bugfix to handle Windows line endings + +## [2.1.17] + +- Switch to search/replace diffs in experimental diff editing mode + +## [2.1.16] + +- Allow copying prompts from the history screen + +## [2.1.15] + +- Incorporate dbasclpy's [PR](https://github.com/RooCodeInc/Roo-Code/pull/54) to add support for gemini-exp-1206 +- Make it clear that diff editing is very experimental + +## [2.1.14] + +- Fix bug where diffs were not being applied correctly and try Aider's [unified diff prompt](https://github.com/Aider-AI/aider/blob/3995accd0ca71cea90ef76d516837f8c2731b9fe/aider/coders/udiff_prompts.py#L75-L105) +- If diffs are enabled, automatically reject write_to_file commands that lead to truncated output + +## [2.1.13] + +- Fix https://github.com/RooCodeInc/Roo-Code/issues/50 where sound effects were not respecting settings + +## [2.1.12] + +- Incorporate JoziGila's [PR](https://github.com/cline/cline/pull/158) to add support for editing through diffs + +## [2.1.11] + +- Incorporate lloydchang's [PR](https://github.com/RooCodeInc/Roo-Code/pull/42) to add support for OpenRouter compression + +## [2.1.10] + +- Incorporate HeavenOSK's [PR](https://github.com/cline/cline/pull/818) to add sound effects to Cline + +## [2.1.9] + +- Add instructions for using .clinerules on the settings screen + +## [2.1.8] + +- Roo Cline now allows configuration of which commands are allowed without approval! + +## [2.1.7] + +- Updated extension icon and metadata + +## [2.2.0] + +- Add support for Model Context Protocol (MCP), enabling Cline to use custom tools like web-search tool or GitHub tool +- Add MCP server management tab accessible via the server icon in the menu bar +- Add ability for Cline to dynamically create new MCP servers based on user requests (e.g., "add a tool that gets the latest npm docs") + +## [2.1.6] + +- Roo Cline now runs in all VSCode-compatible editors + +## [2.1.5] + +- Fix bug in browser action approval + +## [2.1.4] + +- Roo Cline now can run side-by-side with Cline + +## [2.1.3] + +- Roo Cline now allows browser actions without approval when `alwaysAllowBrowser` is true + +## [2.1.2] + +- Support for auto-approval of write operations and command execution +- Support for .clinerules custom instructions diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 8af352425a..eefe9626ae 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -36,6 +36,8 @@ vi.mock("vscode", () => ({ }, env: { language: "en", + isTelemetryEnabled: true, + onDidChangeTelemetryEnabled: vi.fn(), }, ExtensionMode: { Production: 1, @@ -72,19 +74,19 @@ vi.mock("@roo-code/cloud", () => ({ getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) +const mockTelemetryServiceInstance = { + register: vi.fn(), + setProvider: vi.fn(), + shutdown: vi.fn(), + updateTelemetryState: vi.fn(), +} + vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { - createInstance: vi.fn().mockReturnValue({ - register: vi.fn(), - setProvider: vi.fn(), - shutdown: vi.fn(), - }), + createInstance: vi.fn().mockReturnValue(mockTelemetryServiceInstance), + hasInstance: vi.fn().mockReturnValue(true), get instance() { - return { - register: vi.fn(), - setProvider: vi.fn(), - shutdown: vi.fn(), - } + return mockTelemetryServiceInstance }, }, PostHogTelemetryClient: vi.fn(), @@ -114,6 +116,7 @@ vi.mock("../core/config/ContextProxy", () => ({ setValue: vi.fn(), getValues: vi.fn().mockReturnValue({}), getProviderSettings: vi.fn().mockReturnValue({}), + getGlobalState: vi.fn().mockReturnValue("enabled"), }), }, })) @@ -286,7 +289,7 @@ describe("extension.ts", () => { telemetryClient: null, authService: null, hasActiveSession: vi.fn().mockReturnValue(false), - } as any + } as unknown as never }) vi.mocked(CloudService.hasInstance).mockReturnValue(true) @@ -295,7 +298,11 @@ describe("extension.ts", () => { const { activate } = await import("../extension") await activate(mockContext) - const provider = (ClineProvider as any).getVisibleInstance() + const provider = ( + ClineProvider as unknown as { + getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType } + } + ).getVisibleInstance() provider.postStateToWebviewWithoutClineMessages.mockClear() await authStateChangedHandler!({ @@ -317,4 +324,180 @@ describe("extension.ts", () => { await expect(activate(mockContext)).resolves.toBeDefined() }) }) + + describe("telemetry level reactivity", () => { + beforeEach(async () => { + vi.resetModules() + const vscode = await import("vscode") + ;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true + }) + + test("registers a listener for vscode.env.onDidChangeTelemetryEnabled", async () => { + const vscode = await import("vscode") + + const { activate } = await import("../extension") + await activate(mockContext) + + expect(vscode.env.onDidChangeTelemetryEnabled).toHaveBeenCalledTimes(1) + expect(vscode.env.onDidChangeTelemetryEnabled).toHaveBeenCalledWith(expect.any(Function)) + }) + + test("re-evaluates telemetry state from stored settings when VS Code's global toggle changes", async () => { + const vscode = await import("vscode") + const { TelemetryService } = await import("@roo-code/telemetry") + const { ContextProxy } = await import("../core/config/ContextProxy") + + const mockContextProxyInstance = await ( + ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType }> + )() + vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("enabled") + ;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true + + const { activate } = await import("../extension") + await activate(mockContext) + + const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState) + updateTelemetryStateMock.mockClear() + + // The real vscode.env.onDidChangeTelemetryEnabled event carries no payload; the handler + // must read the current vscode.env.isTelemetryEnabled value, not any argument it's called with. + const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] + onDidChangeHandler(undefined as never) + + expect(updateTelemetryStateMock).toHaveBeenCalledWith(true) + }) + + test("treats a disabled stored setting as opted out even when VS Code telemetry is enabled", async () => { + const vscode = await import("vscode") + const { TelemetryService } = await import("@roo-code/telemetry") + const { ContextProxy } = await import("../core/config/ContextProxy") + + const mockContextProxyInstance = await ( + ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType }> + )() + vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("disabled") + ;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true + + const { activate } = await import("../extension") + await activate(mockContext) + + const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState) + updateTelemetryStateMock.mockClear() + + const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] + onDidChangeHandler(undefined as never) + + expect(updateTelemetryStateMock).toHaveBeenCalledWith(false) + }) + + test("treats VS Code's live telemetry-disabled signal as opted out even when the stored setting is enabled", async () => { + const vscode = await import("vscode") + const { TelemetryService } = await import("@roo-code/telemetry") + const { ContextProxy } = await import("../core/config/ContextProxy") + + const mockContextProxyInstance = await ( + ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType }> + )() + vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("enabled") + ;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true + + const { activate } = await import("../extension") + await activate(mockContext) + + const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState) + updateTelemetryStateMock.mockClear() + + // Simulate the user turning off VS Code's global telemetry toggle: the live env value + // flips before the event fires, and the handler must honor it rather than only the + // stored extension setting. + ;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = false + + const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] + onDidChangeHandler(undefined as never) + + expect(updateTelemetryStateMock).toHaveBeenCalledWith(false) + }) + + test("pushes a state update to the webview so its own PostHog client picks up the new vscode.env.isTelemetryEnabled value", async () => { + const vscode = await import("vscode") + const { ClineProvider } = await import("../core/webview/ClineProvider") + + const { activate } = await import("../extension") + await activate(mockContext) + + const visibleInstance = ( + ClineProvider as unknown as { + getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType } + } + ).getVisibleInstance() + vi.mocked(visibleInstance.postStateToWebviewWithoutClineMessages).mockClear() + + const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] + onDidChangeHandler(undefined as never) + + expect(visibleInstance.postStateToWebviewWithoutClineMessages).toHaveBeenCalled() + }) + }) + + describe("deactivate", () => { + beforeEach(() => { + vi.resetModules() + }) + + test("still runs terminal cleanup when telemetry shutdown rejects", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + const { Terminal } = await import("../integrations/terminal/Terminal") + const { TerminalRegistry } = await import("../integrations/terminal/TerminalRegistry") + + vi.mocked(TelemetryService.instance.shutdown).mockRejectedValue(new Error("shutdown failed")) + const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile") + + const { activate, deactivate } = await import("../extension") + await activate(mockContext) + + await expect(deactivate()).resolves.toBeUndefined() + + expect(setTerminalProfileSpy).toHaveBeenCalledWith(undefined) + expect(TerminalRegistry.cleanup).toHaveBeenCalledTimes(1) + + setTerminalProfileSpy.mockRestore() + }) + + // Review finding: every other TelemetryService call site touched by this PR checks + // hasInstance() first; deactivate()'s shutdown call didn't. Not a crash today (the mock + // always resolves), but TelemetryService.instance throws for real if no instance exists, + // so the guard keeps this call site consistent with the rest of the file. + test("does not touch TelemetryService.instance when no instance exists", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + const { Terminal } = await import("../integrations/terminal/Terminal") + const { TerminalRegistry } = await import("../integrations/terminal/TerminalRegistry") + + const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile") + + const { activate, deactivate } = await import("../extension") + await activate(mockContext) + + // Flip to false only after activate() completes, so this only exercises + // deactivate()'s own guard rather than any hasInstance() check during activation. + vi.mocked(TelemetryService.hasInstance).mockReturnValue(false) + + // Model the real singleton failure mode: TelemetryService.instance throws when no + // instance exists. If deactivate()'s hasInstance() guard were ever removed, this + // throw would surface instead of the assertion below silently passing regardless. + const instanceGetterSpy = vi.spyOn(TelemetryService, "instance", "get").mockImplementation(() => { + throw new Error("TelemetryService not initialized") + }) + + await expect(deactivate()).resolves.toBeUndefined() + + expect(instanceGetterSpy).not.toHaveBeenCalled() + expect(mockTelemetryServiceInstance.shutdown).not.toHaveBeenCalled() + expect(setTerminalProfileSpy).toHaveBeenCalledWith(undefined) + expect(TerminalRegistry.cleanup).toHaveBeenCalledTimes(1) + + instanceGetterSpy.mockRestore() + + setTerminalProfileSpy.mockRestore() + }) + }) }) diff --git a/src/__tests__/no-raw-provider-identifiers.spec.mjs b/src/__tests__/no-raw-provider-identifiers.spec.mjs new file mode 100644 index 0000000000..46d981b791 --- /dev/null +++ b/src/__tests__/no-raw-provider-identifiers.spec.mjs @@ -0,0 +1,137 @@ +import { Linter } from "eslint" +import typescriptParser from "@typescript-eslint/parser" +import { describe, expect, it } from "vitest" + +import { noRawProviderIdentifiers } from "../eslint-rules/no-raw-provider-identifiers.mjs" + +const linter = new Linter({ configType: "eslintrc" }) + +linter.defineRule("zoo/no-raw-provider-identifiers", noRawProviderIdentifiers) +linter.defineParser("@typescript-eslint/parser", typescriptParser) + +function lint(code) { + return linter.verify(code, { + parserOptions: { ecmaVersion: 2022, sourceType: "module" }, + rules: { "zoo/no-raw-provider-identifiers": "error" }, + }) +} + +function lintTypeScript(code) { + return linter.verify(code, { + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + warnOnUnsupportedTypeScriptVersion: false, + }, + rules: { "zoo/no-raw-provider-identifiers": "error" }, + }) +} + +describe("no-raw-provider-identifiers", () => { + it("rejects a canonical provider literal in an apiProvider property", () => { + const messages = lint('const config = { apiProvider: "poe" }') + + expect(messages).toHaveLength(1) + expect(messages[0]).toMatchObject({ + ruleId: "zoo/no-raw-provider-identifiers", + message: 'Use providerIdentifiers.poe instead of the raw provider identifier "poe".', + }) + }) + + it("allows a non-canonical literal and a canonical registry member", () => { + expect(lint('const config = { apiProvider: "external-provider" }')).toHaveLength(0) + expect(lint("const config = { apiProvider: providerIdentifiers.poe }")).toHaveLength(0) + }) + + it("matches provider-like property names and static template literals", () => { + const messages = lint('const config = { provider: "poe", imageProvider: `openrouter` }') + + expect(messages).toHaveLength(2) + }) + + it("allows an empty static template in a provider-like context", () => { + expect(lint("const config = { apiProvider: `` }")).toHaveLength(0) + }) + + it("rejects canonical literals in provider-like variable declarations", () => { + const messages = lint(` + const apiProvider = "poe" + let fallbackProvider = \`openrouter\` + const label = "poe" + `) + + expect(messages.map(({ message }) => message)).toEqual([ + 'Use providerIdentifiers.poe instead of the raw provider identifier "poe".', + 'Use providerIdentifiers.openrouter instead of the raw provider identifier "openrouter".', + ]) + }) + + it("rejects canonical provider literals wrapped in TypeScript expressions", () => { + const messages = lintTypeScript(` + const apiProvider = "poe" as ApiProvider + const fallbackProvider = "openrouter" satisfies ApiProvider + const imageProvider = "openai-native" + const nestedProvider = ("anthropic" as ApiProvider)! + `) + + expect(messages.map(({ message }) => message)).toEqual([ + 'Use providerIdentifiers.poe instead of the raw provider identifier "poe".', + 'Use providerIdentifiers.openrouter instead of the raw provider identifier "openrouter".', + 'Use providerIdentifiers.openaiNative instead of the raw provider identifier "openai-native".', + 'Use providerIdentifiers.anthropic instead of the raw provider identifier "anthropic".', + ]) + }) + + it("rejects canonical literals in provider-like class fields", () => { + const messages = lintTypeScript(` + class Settings { + apiProvider = "poe" + label = "openrouter" + } + `) + + expect(messages.map(({ message }) => message)).toEqual([ + 'Use providerIdentifiers.poe instead of the raw provider identifier "poe".', + ]) + }) + + it("rejects canonical literals in provider-like assignments and comparisons", () => { + const messages = lint(` + config["apiProvider"] = "poe" + if (imageProvider === "openrouter") {} + if ("openai-native" !== config.fallbackProvider) {} + `) + + expect(messages.map(({ message }) => message)).toEqual([ + 'Use providerIdentifiers.poe instead of the raw provider identifier "poe".', + 'Use providerIdentifiers.openrouter instead of the raw provider identifier "openrouter".', + 'Use providerIdentifiers.openaiNative instead of the raw provider identifier "openai-native".', + ]) + }) + + it("rejects canonical literals in provider-like switch cases", () => { + const messages = lint(` + switch (config.apiProvider) { + case "poe": break + case providerIdentifiers.openrouter: break + } + `) + + expect(messages).toHaveLength(1) + expect(messages[0].message).toContain("providerIdentifiers.poe") + }) + + it("does not report canonical values outside provider-like contexts", () => { + const messages = lint(` + const label = "poe" + const config = { protocol: "anthropic", format: "openai" } + config[dynamicKey] = "poe" + if (apiProtocol === "anthropic") {} + if (provider > "poe") {} + switch (format) { case "openai": break } + `) + + expect(messages).toHaveLength(0) + }) +}) diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index 9eeec8a960..af1631df9c 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -8,6 +8,7 @@ import { TaskScheduler } from "../core/task/TaskScheduler" import { type Task } from "../core/task/Task" import { API } from "../extension/api" import * as ProfileValidatorMod from "../shared/ProfileValidator" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" type PrivateClineProviderMethods = { createTask: ( @@ -34,6 +35,9 @@ vi.mock("../core/task/Task", () => { public parentTask?: unknown public apiConfiguration: unknown public rootTask?: unknown + public abort = false + public abandoned = false + public abortTask = vi.fn().mockResolvedValue(undefined) constructor(opts: { historyItem?: { id: string } parentTask?: unknown @@ -42,7 +46,7 @@ vi.mock("../core/task/Task", () => { }) { this.taskId = opts.historyItem?.id ?? `task-${Math.random().toString(36).slice(2, 8)}` this.parentTask = opts.parentTask - this.apiConfiguration = opts.apiConfiguration ?? { apiProvider: "anthropic" } + this.apiConfiguration = opts.apiConfiguration ?? { apiProvider: providerIdentifiers.anthropic } opts.onCreated?.(this) } start() {} @@ -83,7 +87,7 @@ describe("Single-open-task invariant", () => { }, setValues: vi.fn(), getState: vi.fn().mockResolvedValue({ - apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, consecutiveMistakeLimit: 0 }, organizationAllowList: "*", enableCheckpoints: true, checkpointTimeout: 60, @@ -127,7 +131,7 @@ describe("Single-open-task invariant", () => { taskScheduler: new TaskScheduler(), setValues: vi.fn(), getState: vi.fn().mockResolvedValue({ - apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, consecutiveMistakeLimit: 0 }, organizationAllowList: "*", enableCheckpoints: true, checkpointTimeout: 60, @@ -179,7 +183,7 @@ describe("Single-open-task invariant", () => { listConfig: vi.fn().mockResolvedValue([]), }, getState: vi.fn().mockResolvedValue({ - apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, consecutiveMistakeLimit: 0 }, enableCheckpoints: true, checkpointTimeout: 60, experiments: {}, @@ -253,7 +257,7 @@ describe("Single-open-task invariant", () => { listConfig: vi.fn().mockResolvedValue([]), }, getState: vi.fn().mockResolvedValue({ - apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, consecutiveMistakeLimit: 0 }, enableCheckpoints: true, checkpointTimeout: 60, experiments: {}, @@ -294,6 +298,88 @@ describe("Single-open-task invariant", () => { expect(removeClineFromStack).not.toHaveBeenCalled() }) + it("serializes concurrent history resumes before mutating the task registry", async () => { + let releaseFirstEviction!: () => void + const firstEvictionGate = new Promise((resolve) => { + releaseFirstEviction = resolve + }) + const registry = new TaskRegistry() + const evictCurrentTask = vi.fn().mockImplementation(async () => { + if (evictCurrentTask.mock.calls.length === 1) { + await firstEvictionGate + } + const current = registry.current + if (current) { + registry.remove(current.taskId) + } + }) + const schedulespy = vi.fn().mockResolvedValue(undefined) + + const provider = { + historyTaskCreationQueue: Promise.resolve(), + getCurrentTask: vi.fn(() => registry.current), + taskRegistry: registry, + taskHistoryStore: { get: vi.fn(() => undefined) }, + evictCurrentTask, + addClineToStack: vi.fn().mockImplementation(async (task: Task) => registry.push(task)), + log: vi.fn(), + customModesManager: { getCustomModes: vi.fn().mockResolvedValue([]) }, + providerSettingsManager: { + getModeConfigId: vi.fn().mockResolvedValue(undefined), + listConfig: vi.fn().mockResolvedValue([]), + }, + getState: vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, consecutiveMistakeLimit: 0 }, + enableCheckpoints: true, + checkpointTimeout: 60, + experiments: {}, + cloudUserInfo: null, + taskSyncEnabled: false, + }), + getPendingEditOperation: vi.fn().mockReturnValue(undefined), + clearPendingEditOperation: vi.fn(), + taskScheduler: { schedule: schedulespy }, + taskEventListeners: new WeakMap(), + performPreparationTasks: vi.fn().mockResolvedValue(undefined), + context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } }, + contextProxy: { + extensionUri: {}, + getValue: vi.fn(), + setValue: vi.fn(), + setProviderSettings: vi.fn(), + getProviderSettings: vi.fn(() => ({})), + }, + postStateToWebview: vi.fn(), + } as unknown as ClineProvider + + const historyItem = { + id: "hist-concurrent-1", + number: 1, + ts: Date.now(), + task: "Task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + workspace: "/tmp", + } + + const firstResume = privateClineProvider.createTaskWithHistoryItem.call(provider, historyItem) + const secondResume = privateClineProvider.createTaskWithHistoryItem.call(provider, historyItem) + + await vi.waitFor(() => expect(evictCurrentTask).toHaveBeenCalledTimes(1)) + expect(provider.getState).not.toHaveBeenCalled() + + releaseFirstEviction() + const [firstTask, secondTask] = await Promise.all([firstResume, secondResume]) + + expect(firstTask).not.toBe(secondTask) + expect(firstTask.abortTask).toHaveBeenCalledWith(true) + expect(evictCurrentTask).toHaveBeenCalledTimes(1) + expect(registry.taskIds).toEqual([historyItem.id]) + expect(registry.current).toBe(secondTask) + expect(schedulespy).toHaveBeenCalledTimes(2) + }) + it("IPC StartNewTask path closes current before new task", async () => { const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const createTask = vi.fn().mockResolvedValue({ taskId: "ipc-1" }) diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index 3617c3cd6d..2fe940f53a 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -47,6 +47,7 @@ import { MimoHandler, MistralHandler, MoonshotHandler, + NanoGptHandler, OpenAiCodexHandler, OpenAiHandler, OpenAiNativeHandler, @@ -99,6 +100,7 @@ const expectedHandlers = { [providerIdentifiers.vercelAiGateway]: VercelAiGatewayHandler, [providerIdentifiers.opencodeGo]: OpencodeGoHandler, [providerIdentifiers.kenari]: KenariHandler, + [providerIdentifiers.nanogpt]: NanoGptHandler, [providerIdentifiers.zooGateway]: ZooGatewayHandler, [providerIdentifiers.minimax]: MiniMaxHandler, [providerIdentifiers.baseten]: BasetenHandler, diff --git a/src/api/index.ts b/src/api/index.ts index f48ab50c0e..8e7f20d66f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -42,6 +42,7 @@ import { VercelAiGatewayHandler, OpencodeGoHandler, KenariHandler, + NanoGptHandler, ZooGatewayHandler, MiniMaxHandler, MimoHandler, @@ -224,6 +225,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new OpencodeGoHandler(options) case providerIdentifiers.kenari: return new KenariHandler(options) + case providerIdentifiers.nanogpt: + return new NanoGptHandler(options) case providerIdentifiers.zooGateway: return new ZooGatewayHandler(options) case providerIdentifiers.minimax: diff --git a/src/api/providers/__tests__/bedrock-reasoning.spec.ts b/src/api/providers/__tests__/bedrock-reasoning.spec.ts index 1577d51f93..d0ee0b8169 100644 --- a/src/api/providers/__tests__/bedrock-reasoning.spec.ts +++ b/src/api/providers/__tests__/bedrock-reasoning.spec.ts @@ -5,6 +5,7 @@ import { BedrockRuntimeClient, ConverseStreamCommand } from "@aws-sdk/client-bed import { logger } from "../../../utils/logging" import { clearAllMocks } from "../../../test-utils/reset" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Mock the AWS SDK vi.mock("@aws-sdk/client-bedrock-runtime") @@ -45,7 +46,7 @@ describe("AwsBedrockHandler - Extended Thinking", () => { describe("Extended Thinking Support", () => { it("should include thinking parameter for Claude Sonnet 4 when reasoning is enabled", async () => { handler = new AwsBedrockHandler({ - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", awsRegion: "us-east-1", enableReasoningEffort: true, @@ -113,7 +114,7 @@ describe("AwsBedrockHandler - Extended Thinking", () => { it("should pass thinking parameters from metadata", async () => { handler = new AwsBedrockHandler({ - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", awsRegion: "us-east-1", }) @@ -156,7 +157,7 @@ describe("AwsBedrockHandler - Extended Thinking", () => { it("should log when extended thinking is enabled", async () => { handler = new AwsBedrockHandler({ - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: "anthropic.claude-opus-4-20250514-v1:0", awsRegion: "us-east-1", enableReasoningEffort: true, @@ -188,7 +189,7 @@ describe("AwsBedrockHandler - Extended Thinking", () => { it("should not include topP when thinking is disabled (global removal)", async () => { handler = new AwsBedrockHandler({ - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", awsRegion: "us-east-1", // Note: no enableReasoningEffort = true, so thinking is disabled @@ -234,7 +235,7 @@ describe("AwsBedrockHandler - Extended Thinking", () => { it("should enable reasoning when enableReasoningEffort is true in settings", async () => { handler = new AwsBedrockHandler({ - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", awsRegion: "us-east-1", enableReasoningEffort: true, // This should trigger reasoning @@ -288,7 +289,7 @@ describe("AwsBedrockHandler - Extended Thinking", () => { it("should support API key authentication", async () => { handler = new AwsBedrockHandler({ - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsRegion: "us-east-1", awsUseApiKey: true, diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 4f3cccdc08..dd2c3d4019 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -214,7 +214,7 @@ describe("DeepSeekHandler", () => { expect(model.info).toBeDefined() expect(model.info.maxTokens).toBe(384_000) expect(model.info.contextWindow).toBe(1_000_000) - expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsImages).toBe(false) expect(model.info.supportsPromptCache).toBe(true) // Should be true now expect((model.info as ModelInfo).preserveReasoning).toBe(true) }) @@ -229,7 +229,7 @@ describe("DeepSeekHandler", () => { expect(model.id).toBe("deepseek-v4-flash") expect(model.info.maxTokens).toBe(384_000) expect(model.info.contextWindow).toBe(1_000_000) - expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsImages).toBe(false) expect((model.info as ModelInfo).supportsReasoningEffort).toContain("max") }) @@ -243,7 +243,7 @@ describe("DeepSeekHandler", () => { expect(model.info).toBeDefined() expect(model.info.maxTokens).toBe(384_000) expect(model.info.contextWindow).toBe(1_000_000) - expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsImages).toBe(false) expect(model.info.supportsPromptCache).toBe(true) expect((model.info as ModelInfo).preserveReasoning).toBe(true) expect((model.info as ModelInfo).reasoningEffort).toBe("high") @@ -617,6 +617,7 @@ describe("DeepSeekHandler", () => { describe("normalizeDeepSeekReasoningEffort", () => { // https://api-docs.deepseek.com/guides/thinking_mode/ + // updated on 2026-08-13 it("should map acceptable reasoning efforts the same way as stated by the official documentation", async () => { const mappings: { modelId: DeepSeekModelId @@ -633,6 +634,11 @@ describe("DeepSeekHandler", () => { rawReasoningEffort: "low", mappedReasoningEffort: "low", }, + { + modelId: "deepseek-v4-flash", + rawReasoningEffort: "medium", + mappedReasoningEffort: "high", + }, { modelId: "deepseek-v4-flash", rawReasoningEffort: "high", @@ -656,6 +662,11 @@ describe("DeepSeekHandler", () => { { modelId: "deepseek-v4-pro", rawReasoningEffort: "low", + mappedReasoningEffort: "low", + }, + { + modelId: "deepseek-v4-pro", + rawReasoningEffort: "medium", mappedReasoningEffort: "high", }, { @@ -666,7 +677,7 @@ describe("DeepSeekHandler", () => { { modelId: "deepseek-v4-pro", rawReasoningEffort: "xhigh", - mappedReasoningEffort: "max", + mappedReasoningEffort: "high", }, { modelId: "deepseek-v4-pro", diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index 353ee31552..bde144591d 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -118,7 +118,14 @@ describe("FireworksHandler", () => { contextWindow: 1048576, inputPrice: 1.74, outputPrice: 3.48, - cacheReadsPrice: 0.14, + cacheReadsPrice: 0.145, + }, + { + modelId: "accounts/fireworks/models/deepseek-v4-pro-0813" as const, + contextWindow: 1_000_000, + inputPrice: 1.32, + outputPrice: 3.96, + cacheReadsPrice: 0.044, }, ])( "should expose newly added model $modelId", diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 0e6b21c5e5..6ac7d9c1fe 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -10,6 +10,7 @@ import { getModelMaxOutputTokens } from "../../../shared/api" import { FriendliHandler } from "../friendli" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import { clearAllMocks } from "../../../test-utils/reset" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Create mock functions const mockCreate = vi.fn() @@ -324,7 +325,7 @@ describe("FriendliHandler", () => { describe("buildApiHandler friendli wiring", () => { it("returns a FriendliHandler for apiProvider='friendli'", () => { - const handler = buildApiHandler({ apiProvider: "friendli", friendliApiKey: "test-key" }) + const handler = buildApiHandler({ apiProvider: providerIdentifiers.friendli, friendliApiKey: "test-key" }) expect(handler).toBeInstanceOf(FriendliHandler) }) }) @@ -335,7 +336,7 @@ describe("Friendli model max output tokens (clamping behavior)", () => { const result = getModelMaxOutputTokens({ modelId: "zai-org/GLM-5.2", model, - settings: { apiProvider: "friendli" }, + settings: { apiProvider: providerIdentifiers.friendli }, format: "openai", }) // 1_000_000 * 0.2 = 200_000 > 131_072 → no clamping @@ -347,7 +348,7 @@ describe("Friendli model max output tokens (clamping behavior)", () => { const result = getModelMaxOutputTokens({ modelId: "zai-org/GLM-5.1", model, - settings: { apiProvider: "friendli" }, + settings: { apiProvider: providerIdentifiers.friendli }, format: "openai", }) // 200_000 * 0.2 = 40_000 < 131_072 → clamped to 40_000 @@ -359,7 +360,7 @@ describe("Friendli model max output tokens (clamping behavior)", () => { const result = getModelMaxOutputTokens({ modelId: "zai-org/GLM-5.1", model, - settings: { apiProvider: "friendli", modelMaxTokens: 80_000 }, + settings: { apiProvider: providerIdentifiers.friendli, modelMaxTokens: 80_000 }, format: "openai", }) // supportsMaxTokens=true, user set 80k, model ceiling 131072 → min(80000, 131072) = 80000 diff --git a/src/api/providers/__tests__/gemini-handler.spec.ts b/src/api/providers/__tests__/gemini-handler.spec.ts index 110f60289c..4f2ec12295 100644 --- a/src/api/providers/__tests__/gemini-handler.spec.ts +++ b/src/api/providers/__tests__/gemini-handler.spec.ts @@ -13,6 +13,7 @@ vi.mock("@roo-code/telemetry", () => ({ import { GeminiHandler } from "../gemini" import type { ApiHandlerOptions } from "../../../shared/api" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" describe("GeminiHandler backend support", () => { beforeEach(() => { @@ -24,7 +25,7 @@ describe("GeminiHandler backend support", () => { // in Gemini API, so createMessage only uses function declarations. // URL context/grounding are only added in completePrompt. const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, enableUrlContext: true, enableGrounding: true, } as ApiHandlerOptions @@ -41,7 +42,7 @@ describe("GeminiHandler backend support", () => { it("completePrompt passes config overrides without tools when URL context and grounding disabled", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, enableUrlContext: false, enableGrounding: false, } as ApiHandlerOptions @@ -58,7 +59,7 @@ describe("GeminiHandler backend support", () => { describe("error scenarios", () => { it("should handle grounding metadata extraction failure gracefully", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, enableGrounding: true, } as ApiHandlerOptions const handler = new GeminiHandler(options) @@ -93,7 +94,7 @@ describe("GeminiHandler backend support", () => { it("should handle malformed grounding metadata", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, enableGrounding: true, } as ApiHandlerOptions const handler = new GeminiHandler(options) @@ -144,7 +145,7 @@ describe("GeminiHandler backend support", () => { it("should handle API errors when tools are enabled", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, enableUrlContext: true, enableGrounding: true, } as ApiHandlerOptions @@ -192,7 +193,7 @@ describe("GeminiHandler backend support", () => { it("should ignore allowedFunctionNames because Gemini rejects larger restriction lists", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) @@ -213,7 +214,7 @@ describe("GeminiHandler backend support", () => { it("should include all tools when allowedFunctionNames is provided", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) @@ -236,7 +237,7 @@ describe("GeminiHandler backend support", () => { it("should not pass large allowedFunctionNames lists to Gemini", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) @@ -267,7 +268,7 @@ describe("GeminiHandler backend support", () => { it("should not pass allowedFunctionNames even when history includes tool calls", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) @@ -304,7 +305,7 @@ describe("GeminiHandler backend support", () => { it("should fall back to tool_choice when allowedFunctionNames is provided", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) @@ -327,7 +328,7 @@ describe("GeminiHandler backend support", () => { it("should fall back to tool_choice when allowedFunctionNames is empty", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) @@ -351,7 +352,7 @@ describe("GeminiHandler backend support", () => { it("should not set toolConfig when allowedFunctionNames is undefined and no tool_choice", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) @@ -374,7 +375,7 @@ describe("GeminiHandler backend support", () => { describe("Gemini schema compatibility", () => { it("should strip broad JSON Schema metadata from function declarations", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) @@ -435,7 +436,7 @@ describe("GeminiHandler backend support", () => { it("should collapse composition and type arrays in function declaration schemas", async () => { const options = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) @@ -495,7 +496,7 @@ describe("GeminiHandler backend support", () => { }) it("should deep-merge allOf fragments instead of overwriting earlier properties", async () => { - const options = { apiProvider: "gemini" } as ApiHandlerOptions + const options = { apiProvider: providerIdentifiers.gemini } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) // @ts-ignore access private client @@ -540,7 +541,7 @@ describe("GeminiHandler backend support", () => { }) it("should resolve $ref entries before dropping $defs", async () => { - const options = { apiProvider: "gemini" } as ApiHandlerOptions + const options = { apiProvider: providerIdentifiers.gemini } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) // @ts-ignore access private client @@ -589,7 +590,7 @@ describe("GeminiHandler backend support", () => { }) it("should preserve top-level properties and required entries when allOf is also present", async () => { - const options = { apiProvider: "gemini" } as ApiHandlerOptions + const options = { apiProvider: providerIdentifiers.gemini } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) // @ts-ignore access private client @@ -631,7 +632,7 @@ describe("GeminiHandler backend support", () => { }) it("should stop recursive $ref expansion before the sanitized schema becomes cyclic", async () => { - const options = { apiProvider: "gemini" } as ApiHandlerOptions + const options = { apiProvider: providerIdentifiers.gemini } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) // @ts-ignore access private client @@ -683,7 +684,7 @@ describe("GeminiHandler backend support", () => { }) it("should preserve parameter names that collide with stripped schema keywords", async () => { - const options = { apiProvider: "gemini" } as ApiHandlerOptions + const options = { apiProvider: providerIdentifiers.gemini } as ApiHandlerOptions const handler = new GeminiHandler(options) const stub = vi.fn().mockReturnValue((async function* () {})()) // @ts-ignore access private client diff --git a/src/api/providers/__tests__/kenari.spec.ts b/src/api/providers/__tests__/kenari.spec.ts index f9d07873c6..ce629f0377 100644 --- a/src/api/providers/__tests__/kenari.spec.ts +++ b/src/api/providers/__tests__/kenari.spec.ts @@ -34,6 +34,10 @@ vitest.mock("../fetchers/modelCache", () => ({ }, }), ), + refreshModels: vitest.fn(async (options) => { + const { getModels } = await import("../fetchers/modelCache") + return getModels(options) + }), getModelsFromCache: vitest.fn().mockReturnValue(undefined), })) diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts index df909d57d4..fe229910b8 100644 --- a/src/api/providers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -2,6 +2,7 @@ import { buildApiHandler } from "../../index" import { KimiCodeHandler } from "../kimi-code" import { clearAllMocks } from "../../../test-utils/reset" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" const { mockGetAccessToken, mockForceRefreshAccessToken, mockGetModels } = vi.hoisted(() => ({ mockGetAccessToken: vi.fn(), @@ -16,7 +17,10 @@ vi.mock("../../../integrations/kimi-code/oauth", () => ({ }, })) -vi.mock("../fetchers/modelCache", () => ({ getModels: mockGetModels })) +vi.mock("../fetchers/modelCache", () => ({ + getModels: mockGetModels, + refreshModels: mockGetModels, +})) describe("KimiCodeHandler", () => { beforeEach(() => { @@ -28,7 +32,7 @@ describe("KimiCodeHandler", () => { it("is dispatched separately from Moonshot and preserves an unknown selected model", () => { const handler = buildApiHandler({ - apiProvider: "kimi-code", + apiProvider: providerIdentifiers.kimiCode, kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "kimi-key", apiModelId: "future-kimi-model", diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index eee5cf52bb..20bd3b1be2 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -58,6 +58,10 @@ vi.mock("../fetchers/modelCache", () => ({ "vertex_ai/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 }, }) }), + refreshModels: vi.fn(async (options) => { + const { getModels } = await import("../fetchers/modelCache") + return getModels(options) + }), getModelsFromCache: vi.fn().mockReturnValue(undefined), })) diff --git a/src/api/providers/__tests__/lmstudio.spec.ts b/src/api/providers/__tests__/lmstudio.spec.ts index 0fde087957..7ab674a0a9 100644 --- a/src/api/providers/__tests__/lmstudio.spec.ts +++ b/src/api/providers/__tests__/lmstudio.spec.ts @@ -112,6 +112,89 @@ describe("LmStudioHandler", () => { expect(textChunks[0].text).toBe("Test response") }) + it("streams reasoning chunks from delta.reasoning_content", async () => { + // Regression: Qwen3 / DeepSeek-R1 style models served by LM Studio emit + // thinking via reasoning_content, not tags inside content. + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] }, + { choices: [{ delta: { content: "answer" }, index: 0 }] }, + { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." }) + expect(chunks).toContainEqual({ type: "text", text: "answer" }) + }) + + it("falls back to delta.reasoning when reasoning_content is absent", async () => { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }, + { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(chunks).toContainEqual({ type: "reasoning", text: "router-style thought" }) + }) + + it("prefers delta.reasoning_content over delta.reasoning when both are present", async () => { + // When both reasoning_content and reasoning are set, only reasoning_content + // should be emitted as a reasoning chunk (not both). + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { + choices: [ + { + delta: { + reasoning_content: "primary thought", + reasoning: "fallback thought", + }, + index: 0, + }, + ], + }, + { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + + expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }]) + }) + + it("still parses tags embedded in content", async () => { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { content: "tagged thoughtvisible" }, index: 0 }] }, + { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(chunks).toContainEqual({ type: "reasoning", text: "tagged thought" }) + expect(chunks).toContainEqual({ type: "text", text: "visible" }) + }) + it("should handle API errors", async () => { mockCreate.mockRejectedValueOnce(new Error("API Error")) diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts new file mode 100644 index 0000000000..3d13ea8693 --- /dev/null +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -0,0 +1,324 @@ +vi.mock("vscode", () => ({ + workspace: { getConfiguration: () => ({ get: (_key: string, defaultValue?: unknown) => defaultValue }) }, +})) + +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { nanoGptDefaultModelId, providerIdentifiers } from "@roo-code/types" + +import { buildApiHandler } from "../../index" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { NanoGptHandler } from "../nanogpt" +import { getModels } from "../fetchers/modelCache" + +vi.mock("openai") +vi.mock("../fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsImages: true, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "medium", "high"], + }, + }), + refreshModels: vi.fn().mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsImages: true, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "medium", "high"], + }, + }), + getModelsFromCache: vi.fn(), +})) + +const mockCreate = vi.fn() +vi.mocked(OpenAI).mockImplementation(function () { + return { chat: { completions: { create: mockCreate } } } as unknown as OpenAI +}) + +const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + +describe("NanoGptHandler", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsImages: true, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "medium", "high"], + }, + }) + mockCreate.mockResolvedValue(asyncStreamFrom([])) + }) + + it("is constructed by the backend provider registry", () => { + expect(buildApiHandler({ apiProvider: providerIdentifiers.nanogpt })).toBeInstanceOf(NanoGptHandler) + }) + + it("keeps the canonical model ID while applying request-only routing", async () => { + const handler = new NanoGptHandler({ nanoGptModelId: "model:thinking", nanoGptRoutingPreference: "fast" }) + await collectStream(handler.createMessage("system", messages)) + expect(handler.getModel().id).toBe("model:thinking") + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: "model:thinking:fast" }), + expect.anything(), + ) + }) + + it.each([ + ["auto", "model:thinking"], + ["fast", "model:thinking:fast"], + ["cheap", "model:thinking:cheap"], + ["latency", "model:thinking:latency"], + ["throughput", "model:thinking:throughput"], + ["tools", "model:thinking:tools"], + ] as const)("sends %s routing", async (preference, expected) => { + const handler = new NanoGptHandler({ nanoGptModelId: "model:thinking", nanoGptRoutingPreference: preference }) + await collectStream(handler.createMessage("system", messages)) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expected }), expect.anything()) + }) + + it("requests cache-capable routing without changing the streaming model ID", async () => { + const handler = new NanoGptHandler({ + nanoGptModelId: "model:thinking", + nanoGptRoutingPreference: "caching", + }) + await collectStream(handler.createMessage("system", messages)) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: "model:thinking", caching: true, stream: true }), + expect.anything(), + ) + }) + + it("streams interleaved text, both reasoning variants, and parallel tool calls", async () => { + mockCreate.mockResolvedValue( + asyncStreamFrom([ + { choices: [{ delta: { content: "answer", reasoning: "modern" } }] }, + { choices: [{ delta: { reasoning_content: "legacy" } }] }, + { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: "call-1", function: { name: "read_file", arguments: '{"path":' } }, + { + index: 1, + id: "call-2", + function: { name: "search_files", arguments: '{"query":' }, + }, + ], + }, + }, + ], + }, + ]), + ) + const chunks = await collectStream( + new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages), + ) + expect(chunks).toEqual([ + { type: "text", text: "answer" }, + { type: "reasoning", text: "modern" }, + { type: "reasoning", text: "legacy" }, + { type: "tool_call_partial", index: 0, id: "call-1", name: "read_file", arguments: '{"path":' }, + { type: "tool_call_partial", index: 1, id: "call-2", name: "search_files", arguments: '{"query":' }, + ]) + }) + + it("forwards native tools, choices, usage streaming, max_tokens, reasoning effort, and cancellation", async () => { + const signal = new AbortController().signal + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { type: "function", function: { name: "read_file", description: "Read", parameters: { type: "object" } } }, + ] + const handler = new NanoGptHandler({ + nanoGptModelId: "model:thinking", + modelTemperature: 0.7, + reasoningEffort: "high", + }) + await collectStream( + handler.createMessage("sys", messages, { + taskId: "task", + tools, + tool_choice: "required", + parallelToolCalls: false, + abortSignal: signal, + }), + ) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + stream: true, + stream_options: { include_usage: true }, + max_tokens: 128000, + temperature: 0.7, + reasoning_effort: "high", + tools: [ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "read_file", description: "Read" }), + }), + ], + tool_choice: "required", + parallel_tool_calls: false, + }), + { signal }, + ) + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("max_completion_tokens") + }) + + it("omits temperature when it was not explicitly configured", async () => { + await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages)) + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") + }) + + it("keeps unauthenticated catalog fetches public and preserves streaming error metadata while redacting", async () => { + const errorDetails = [{ "@type": "type.googleapis.com/google.rpc.RetryInfo", retryDelay: "10s" }] + mockCreate.mockRejectedValue( + Object.assign(new Error("upstream rejected secret-key"), { + status: 429, + code: "rate_limit_exceeded", + errorDetails, + }), + ) + const handler = new NanoGptHandler({ nanoGptApiKey: "secret-key", nanoGptModelId: "model:thinking" }) + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toMatchObject({ + message: "NanoGPT streaming error: upstream rejected [REDACTED]", + status: 429, + code: "rate_limit_exceeded", + errorDetails, + }) + + vi.mocked(getModels).mockResolvedValue({}) + mockCreate.mockResolvedValue(asyncStreamFrom([])) + await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages)) + expect(getModels).toHaveBeenLastCalledWith( + expect.objectContaining({ provider: providerIdentifiers.nanogpt, apiKey: undefined }), + ) + }) + + it("maps usage with root-field precedence and no reasoning double count", async () => { + mockCreate.mockResolvedValue( + asyncStreamFrom([ + { + choices: [], + usage: { + prompt_tokens: 20, + completion_tokens: 10, + cache_read_input_tokens: 7, + cache_creation_input_tokens: 3, + prompt_tokens_details: { cached_tokens: 5 }, + completion_tokens_details: { reasoning_tokens: 4 }, + reasoning_tokens: 2, + }, + }, + ]), + ) + expect( + await collectStream( + new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages), + ), + ).toEqual([ + { + type: "usage", + inputTokens: 20, + outputTokens: 10, + cacheReadTokens: 7, + cacheWriteTokens: 3, + reasoningTokens: 4, + }, + ]) + }) + + it("falls back to nested cache reads and root reasoning tokens", async () => { + mockCreate.mockResolvedValue( + asyncStreamFrom([ + { + choices: [], + usage: { + prompt_tokens: 2, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 1 }, + reasoning_tokens: 1, + }, + }, + ]), + ) + expect( + await collectStream( + new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages), + ), + ).toEqual([ + { + type: "usage", + inputTokens: 2, + outputTokens: 1, + cacheReadTokens: 1, + cacheWriteTokens: undefined, + reasoningTokens: 1, + }, + ]) + }) + + describe("completePrompt", () => { + it("requests cache-capable routing without changing the completion model ID", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + const handler = new NanoGptHandler({ + nanoGptModelId: "model:thinking", + nanoGptRoutingPreference: "caching", + }) + await handler.completePrompt("prompt") + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + model: "model:thinking", + caching: true, + stream: false, + }) + }) + + it("returns normal and empty content", async () => { + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + const handler = new NanoGptHandler({ nanoGptModelId: "model:thinking" }) + expect(await handler.completePrompt("prompt")).toBe("response") + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + model: "model:thinking", + stream: false, + max_tokens: 128000, + }) + + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: null } }] }) + expect(await handler.completePrompt("prompt")).toBe("") + }) + + it("preserves completion error metadata without leaking the API key", async () => { + const errorDetails = [{ "@type": "type.googleapis.com/google.rpc.RetryInfo", retryDelay: "20s" }] + mockCreate.mockRejectedValue( + Object.assign(new Error("upstream rejected secret-key"), { + status: 429, + code: "rate_limit_exceeded", + errorDetails, + }), + ) + const handler = new NanoGptHandler({ nanoGptApiKey: "secret-key", nanoGptModelId: "model:thinking" }) + await expect(handler.completePrompt("prompt")).rejects.toMatchObject({ + message: "NanoGPT completion error: upstream rejected [REDACTED]", + status: 429, + code: "rate_limit_exceeded", + errorDetails, + }) + }) + }) + + it("uses the documented fallback model", async () => { + vi.mocked(getModels).mockResolvedValue({}) + const handler = new NanoGptHandler({}) + await collectStream(handler.createMessage("sys", messages)) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: nanoGptDefaultModelId }), + expect.anything(), + ) + }) +}) diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts index 721e795eb4..75ad5ff077 100644 --- a/src/api/providers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/__tests__/opencode-go.spec.ts @@ -36,6 +36,12 @@ vitest.mock("../fetchers/modelCache", () => ({ "qwen3.7-max": { ...opencodeGoModels["qwen3.7-max"] }, }) }), + refreshModels: vitest.fn().mockImplementation(function () { + return Promise.resolve({ + "glm-5.1": { ...opencodeGoModels["glm-5.1"] }, + "qwen3.7-max": { ...opencodeGoModels["qwen3.7-max"] }, + }) + }), getModelsFromCache: vitest.fn().mockReturnValue(undefined), })) @@ -802,7 +808,7 @@ describe("OpencodeGoHandler", () => { }) it("classifies OpenAI-compatible Go models as non-Anthropic-format", () => { - expect(isOpencodeGoAnthropicFormatModel("glm-5.2")).toBe(false) + expect(isOpencodeGoAnthropicFormatModel("glm-5.3")).toBe(false) expect(isOpencodeGoAnthropicFormatModel("kimi-k2.6")).toBe(false) expect(isOpencodeGoAnthropicFormatModel("deepseek-v4-pro")).toBe(false) expect(isOpencodeGoAnthropicFormatModel("mimo-v2.5")).toBe(false) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index f0000918d8..18b6286d09 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -17,6 +17,8 @@ const MOCK_TIMEOUT_MS = 300_000 import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" +import { providerIdentifiers } from "@roo-code/types" + import { OpenRouterHandler } from "../openrouter" import { Package } from "../../../shared/package" import { makeApiHandlerOptions } from "../../../test-utils/api" @@ -100,6 +102,10 @@ vitest.mock("../fetchers/modelCache", () => ({ }, }) }), + refreshModels: vitest.fn(async (options) => { + const { getModels } = await import("../fetchers/modelCache") + return getModels(options) + }), })) describe("OpenRouterHandler", () => { @@ -349,7 +355,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "API Error", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "createMessage", errorCode: 500, @@ -371,7 +377,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "Connection failed", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "createMessage", }), @@ -394,7 +400,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "Rate limit exceeded: free-models-per-day", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "createMessage", }), @@ -415,7 +421,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "429 Rate limit exceeded: free-models-per-day", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "createMessage", }), @@ -436,7 +442,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "Request failed due to rate limit", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "createMessage", }), @@ -458,7 +464,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "Rate limit exceeded", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "createMessage", errorCode: 429, @@ -585,7 +591,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "API Error", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "completePrompt", errorCode: 500, @@ -608,7 +614,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "Unexpected error", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "completePrompt", }), @@ -630,7 +636,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "Rate limit exceeded: free-models-per-day", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "completePrompt", }), @@ -651,7 +657,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "429 Rate limit exceeded: free-models-per-day", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "completePrompt", }), @@ -672,7 +678,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "Request failed due to rate limit", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "completePrompt", }), @@ -701,7 +707,7 @@ describe("OpenRouterHandler", () => { expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "Rate limit exceeded", - provider: "OpenRouter", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "completePrompt", errorCode: 429, diff --git a/src/api/providers/__tests__/poe.spec.ts b/src/api/providers/__tests__/poe.spec.ts index 627d203994..00712924f5 100644 --- a/src/api/providers/__tests__/poe.spec.ts +++ b/src/api/providers/__tests__/poe.spec.ts @@ -1,6 +1,55 @@ -const mockStreamText = vitest.fn() -const mockGenerateText = vitest.fn() -const mockCreatePoe = vitest.fn() +import { poeDefaultModelId, providerIdentifiers } from "@roo-code/types" + +import { PoeHandler } from "../poe" +import { getModelsFromCache } from "../fetchers/modelCache" + +import { clearAllMocks } from "../../../test-utils/reset" + +const { mockStreamText, mockGenerateText, mockCreatePoe, mockGetModelsFromCache, mockCaptureException } = + vitest.hoisted(() => ({ + mockStreamText: vitest.fn(), + mockGenerateText: vitest.fn(), + mockCreatePoe: vitest.fn(), + mockCaptureException: vitest.fn(), + mockGetModelsFromCache: vitest.fn(), + })) + +const cachedModels = { + "anthropic/claude-sonnet-4": { + maxTokens: 10_000, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningBudget: true, + inputPrice: 3, + outputPrice: 15, + }, + "openai/gpt-4o": { + maxTokens: 16_384, + contextWindow: 128_000, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 2.5, + outputPrice: 10, + }, + "openai/o3": { + maxTokens: 100_000, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "medium", "high"], + inputPrice: 10, + outputPrice: 40, + }, +} + +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: (...args: unknown[]) => mockCaptureException(...args), + }, + }, +})) vitest.mock("ai-sdk-provider-poe", () => ({ createPoe: (...args: unknown[]) => mockCreatePoe(...args), @@ -41,41 +90,9 @@ vitest.mock("ai", async (importOriginal) => { }) vitest.mock("../fetchers/modelCache", () => ({ - getModelsFromCache: vitest.fn().mockReturnValue({ - "anthropic/claude-sonnet-4": { - maxTokens: 10_000, - contextWindow: 200_000, - supportsImages: true, - supportsPromptCache: true, - supportsReasoningBudget: true, - inputPrice: 3, - outputPrice: 15, - }, - "openai/gpt-4o": { - maxTokens: 16_384, - contextWindow: 128_000, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 2.5, - outputPrice: 10, - }, - "openai/o3": { - maxTokens: 100_000, - contextWindow: 200_000, - supportsImages: true, - supportsPromptCache: false, - supportsReasoningEffort: ["low", "medium", "high"], - inputPrice: 10, - outputPrice: 40, - }, - }), + getModelsFromCache: mockGetModelsFromCache, })) -import { poeDefaultModelId } from "@roo-code/types" -import { PoeHandler } from "../poe" - -import { clearAllMocks } from "../../../test-utils/reset" - describe("PoeHandler", () => { const mockLanguageModel = { modelId: "test-model" } const mockPoeProvider = vitest.fn().mockReturnValue(mockLanguageModel) @@ -83,6 +100,7 @@ describe("PoeHandler", () => { beforeEach(() => { clearAllMocks() mockCreatePoe.mockReturnValue(mockPoeProvider) + mockGetModelsFromCache.mockReturnValue(cachedModels) }) describe("constructor", () => { @@ -116,9 +134,19 @@ describe("PoeHandler", () => { describe("getModel", () => { it("returns model info from cache", () => { - const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "anthropic/claude-sonnet-4" }) + const options = { + poeApiKey: "key", + poeBaseUrl: "https://custom.poe.com/v1", + apiModelId: "anthropic/claude-sonnet-4", + } + const handler = new PoeHandler(options) const result = handler.getModel() + expect(getModelsFromCache).toHaveBeenCalledWith({ + provider: providerIdentifiers.poe, + apiKey: options.poeApiKey, + baseUrl: options.poeBaseUrl, + }) expect(result.id).toBe("anthropic/claude-sonnet-4") expect(result.info.contextWindow).toBe(200_000) expect(result.info.maxTokens).toBe(10_000) @@ -166,6 +194,49 @@ describe("PoeHandler", () => { expect(chunks).toContainEqual({ type: "text", text: "world!" }) expect(chunks).toContainEqual(expect.objectContaining({ type: "usage", inputTokens: 10, outputTokens: 5 })) }) + + it("reports synchronous completion failures with the canonical provider identifier", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + mockStreamText.mockImplementationOnce(() => { + throw new Error("request failed") + }) + + await expect( + handler.createMessage("system", [{ role: "user" as const, content: "hello" }]).next(), + ).rejects.toThrow("Poe completion error: request failed") + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + provider: providerIdentifiers.poe, + modelId: "openai/gpt-4o", + operation: "createMessage", + }), + ) + }) + + it("reports asynchronous stream failures with the canonical provider identifier", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + const failedStream = { + [Symbol.asyncIterator]() { + return this + }, + next: vitest.fn().mockRejectedValueOnce(new Error("stream failed")), + } + mockStreamText.mockReturnValueOnce({ + fullStream: failedStream, + usage: Promise.resolve(undefined), + }) + + await expect( + handler.createMessage("system", [{ role: "user" as const, content: "hello" }]).next(), + ).rejects.toThrow("Poe streaming error: stream failed") + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + provider: providerIdentifiers.poe, + modelId: "openai/gpt-4o", + operation: "createMessage", + }), + ) + }) }) describe("reasoning", () => { @@ -311,5 +382,21 @@ describe("PoeHandler", () => { }), ) }) + + it("reports failures with the canonical provider identifier", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + mockGenerateText.mockRejectedValueOnce(new Error("generation failed")) + + await expect(handler.completePrompt("complete this")).rejects.toThrow( + "Poe completion error: generation failed", + ) + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + provider: providerIdentifiers.poe, + modelId: "openai/gpt-4o", + operation: "completePrompt", + }), + ) + }) }) }) diff --git a/src/api/providers/__tests__/request-config-builder.spec.ts b/src/api/providers/__tests__/request-config-builder.spec.ts new file mode 100644 index 0000000000..977b09df6b --- /dev/null +++ b/src/api/providers/__tests__/request-config-builder.spec.ts @@ -0,0 +1,508 @@ +import { describe, expect, test, vi } from "vitest" + +import { makeCreateMessageMetadata } from "../../../test-utils/api" +import { RequestConfigBuilder } from "../config-builder/request-config-builder" + +describe("RequestConfigBuilder", () => { + describe("constructor", () => { + test("should initialize with empty options by default", () => { + const builder = new RequestConfigBuilder() + expect(builder.build()).toBeUndefined() + }) + + test("should initialize with provided defaultOptions", () => { + const defaults = { modelId: "test-model" } + const builder = new RequestConfigBuilder(defaults) + const result = builder.build() + expect(result).toEqual({ modelId: "test-model" }) + }) + + test("should create a shallow copy of defaultOptions", () => { + const defaults = { modelId: "test-model" } + const builder = new RequestConfigBuilder(defaults) + defaults.modelId = "modified-model" + const result = builder.build() + expect(result?.modelId).toBe("test-model") + }) + + test("should ignore undefined values from defaultOptions", () => { + const builder = new RequestConfigBuilder({ modelId: undefined }) + + expect(builder.build()).toBeUndefined() + }) + + test("should keep falsy-but-defined values", () => { + const builder = new RequestConfigBuilder({ count: 0, enabled: false, label: "" }) + + expect(builder.build()).toEqual({ count: 0, enabled: false, label: "" }) + }) + + test("should not alias the caller's default headers", () => { + const defaults = { headers: { A: "1" } } + const builder = new RequestConfigBuilder(defaults) + + defaults.headers.A = "2" + + expect(builder.getOption("headers")).toEqual({ A: "1" }) + }) + }) + + describe("setAbortSignal", () => { + test("should set signal when metadata contains abortSignal", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + const builder = new RequestConfigBuilder() + const result = builder.setAbortSignal(metadata) + + expect(result).toBe(builder) // chainable + const config = builder.build() as { signal?: AbortSignal } + expect(config?.signal).toBe(controller.signal) + }) + + test("should do nothing when metadata is undefined", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + builder.setAbortSignal(undefined) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).toBeUndefined() + }) + + test("should do nothing when metadata.abortSignal is undefined", () => { + const metadata = makeCreateMessageMetadata() + + const builder = new RequestConfigBuilder({ initial: "value" }) + builder.setAbortSignal(metadata) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).toBeUndefined() + }) + + test("should replace existing signal if metadata contains abortSignal", () => { + const controller1 = new AbortController() + const controller2 = new AbortController() + + const builder = new RequestConfigBuilder({ signal: controller1.signal }) + builder.setAbortSignal(makeCreateMessageMetadata({ abortSignal: controller2.signal })) + + const config = builder.build() as { signal?: AbortSignal } + expect(config?.signal).toBe(controller2.signal) + }) + + test("should support chaining with other methods", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + const builder = new RequestConfigBuilder() + const result = builder.setAbortSignal(metadata).setOption("customKey", "customValue") + + expect(result).toBe(builder) + const config = builder.build() as { signal?: AbortSignal; customKey?: string } + expect(config?.signal).toBe(controller.signal) + expect(config?.customKey).toBe("customValue") + }) + }) + + describe("addHeaders", () => { + test("should merge headers when provided", () => { + const builder = new RequestConfigBuilder() + const result = builder.addHeaders({ "X-Custom": "value1" }) + + expect(result).toBe(builder) // chainable + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-Custom": "value1" }) + }) + + test("should do nothing when headers are undefined", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + const result = builder.addHeaders() + + expect(result).toBe(builder) // chainable + const config = builder.build() as { headers?: Record } + expect(config.headers).toBeUndefined() + }) + + test("should do nothing when headers object is empty", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + const result = builder.addHeaders({}) + + expect(result).toBe(builder) // chainable + const config = builder.build() as { headers?: Record } + expect(config.headers).toBeUndefined() + }) + + test("should override existing header values", () => { + const builder = new RequestConfigBuilder({ headers: { "X-Existing": "old" } }) + builder.addHeaders({ "X-Existing": "new" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers?.["X-Existing"]).toBe("new") + }) + + test("should merge with existing headers without overwriting unrelated keys", () => { + const builder = new RequestConfigBuilder({ headers: { "X-Existing": "value" } }) + builder.addHeaders({ "X-New": "newValue" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-Existing": "value", "X-New": "newValue" }) + }) + + test("should create headers object if none exists", () => { + const builder = new RequestConfigBuilder() + builder.addHeaders({ "X-Custom": "value" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-Custom": "value" }) + }) + + test("should support chaining with other methods", () => { + const builder = new RequestConfigBuilder() + builder.addHeaders({ "X-First": "1" }).addHeaders({ "X-Second": "2" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-First": "1", "X-Second": "2" }) + }) + }) + + describe("setOption", () => { + test("should set option when value is defined", () => { + const builder = new RequestConfigBuilder() + const result = builder.setOption("modelId", "test-model") + + expect(result).toBe(builder) // chainable + const config = builder.build() as { modelId?: string } + expect(config?.modelId).toBe("test-model") + }) + + test("should do nothing when value is undefined", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + builder.setOption("initial", undefined as unknown as string) + + const config = builder.build() as { initial?: string } + // When setOption receives undefined, it should NOT modify the existing value + expect(config.initial).toBe("value") + }) + + test("should replace existing option value", () => { + const builder = new RequestConfigBuilder({ modelId: "old-model" }) + builder.setOption("modelId", "new-model") + + const config = builder.build() as { modelId?: string } + expect(config?.modelId).toBe("new-model") + }) + + test("should support different value types", () => { + const builder = new RequestConfigBuilder() + + builder.setOption("stringKey", "stringValue") + builder.setOption("numberKey", 42) + builder.setOption("booleanKey", true) + builder.setOption("objectKey", { nested: true }) + + const config = builder.build() as { + stringKey?: string + numberKey?: number + booleanKey?: boolean + objectKey?: { nested: boolean } + } + expect(config.stringKey).toBe("stringValue") + expect(config.numberKey).toBe(42) + expect(config.booleanKey).toBe(true) + expect(config.objectKey).toEqual({ nested: true }) + }) + + test("should keep falsy-but-defined values", () => { + const builder = new RequestConfigBuilder() + builder.setOption("count", 0).setOption("enabled", false).setOption("label", "") + + const config = builder.build() as { count?: number; enabled?: boolean; label?: string } + expect(config.count).toBe(0) + expect(config.enabled).toBe(false) + expect(config.label).toBe("") + }) + + test("should support chaining", () => { + const builder = new RequestConfigBuilder() + const result = builder.setOption("key1", "value1").setOption("key2", "value2") + + expect(result).toBe(builder) + const config = builder.build() as { key1?: string; key2?: string } + expect(config.key1).toBe("value1") + expect(config.key2).toBe("value2") + }) + }) + + describe("getOption", () => { + test("should return existing option value", () => { + const builder = new RequestConfigBuilder({ modelId: "test-model" }) + expect(builder.getOption("modelId")).toBe("test-model") + }) + + test("should return undefined for non-existent key", () => { + const builder = new RequestConfigBuilder() + expect(builder.getOption("nonExistent")).toBeUndefined() + }) + }) + + describe("build", () => { + test("should return shallow copy of options", () => { + const builder = new RequestConfigBuilder({ key: "value" }) + const result1 = builder.build() + const result2 = builder.build() + + expect(result1).toEqual(result2) + expect(result1).not.toBe(result2) // different references + }) + + test("should return undefined when options are empty", () => { + const builder = new RequestConfigBuilder() + expect(builder.build()).toBeUndefined() + }) + + test("modifying build result should not affect internal state", () => { + const builder = new RequestConfigBuilder({ key: "value" }) + const result = builder.build() as { key: string } + + result.key = "modified" + expect(builder.getOption("key")).toBe("value") + }) + + test("mutating returned headers should not affect internal state", () => { + const builder = new RequestConfigBuilder({ headers: { Authorization: "Bearer x" } }) + const config = builder.build() as { headers?: Record } + + config.headers!.Authorization = "TAMPERED" + + expect(builder.getOption("headers")).toEqual({ Authorization: "Bearer x" }) + }) + + test("should return all set options", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + const builder = new RequestConfigBuilder() + builder.setAbortSignal(metadata).addHeaders({ "X-Custom": "value" }).setOption("modelId", "test-model") + + const config = builder.build() as { + signal?: AbortSignal + headers?: Record + modelId?: string + } + expect(config.signal).toBe(controller.signal) + expect(config.headers).toEqual({ "X-Custom": "value" }) + expect(config.modelId).toBe("test-model") + }) + }) + + describe("static fromMetadata", () => { + test("should return undefined when both metadata and extraOptions are undefined", () => { + const result = RequestConfigBuilder.fromMetadata() + expect(result).toBeUndefined() + }) + + test("should set signal from metadata.abortSignal", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + const result = RequestConfigBuilder.fromMetadata(metadata) as { signal?: AbortSignal } + expect(result.signal).toBe(controller.signal) + }) + + test("should merge extraOptions with metadata signal", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const extraOptions = { modelId: "test-model", customKey: "customValue" } + + const result = RequestConfigBuilder.fromMetadata(metadata, extraOptions) as { + signal?: AbortSignal + modelId?: string + customKey?: string + } + expect(result.signal).toBe(controller.signal) + expect(result.modelId).toBe("test-model") + expect(result.customKey).toBe("customValue") + }) + + test("should return only extraOptions when metadata is undefined", () => { + const extraOptions = { modelId: "test-model" } + const result = RequestConfigBuilder.fromMetadata(undefined, extraOptions) as { modelId?: string } + expect(result.modelId).toBe("test-model") + }) + + test("should treat undefined extraOptions values as absent", () => { + const result = RequestConfigBuilder.fromMetadata(undefined, { signal: undefined }) + + expect(result).toBeUndefined() + }) + + test("should not set signal when metadata.abortSignal is undefined", () => { + const metadata = makeCreateMessageMetadata() + const extraOptions = { modelId: "test-model" } + + const result = RequestConfigBuilder.fromMetadata(metadata, extraOptions) as { + signal?: AbortSignal + modelId?: string + } + expect(result.signal).toBeUndefined() + expect(result.modelId).toBe("test-model") + }) + }) + + describe("addMergedSignal", () => { + test("should add internal controller signal when metadata and timeout are absent", () => { + const internalController = new AbortController() + const builder = new RequestConfigBuilder() + + const result = builder.addMergedSignal(internalController) + + expect(result).toBe(builder) + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).toBe(internalController.signal) + }) + + test("should merge internal controller signal with metadata abort signal", () => { + const internalController = new AbortController() + const externalController = new AbortController() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal( + internalController, + makeCreateMessageMetadata({ abortSignal: externalController.signal }), + ) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).not.toBe(internalController.signal) + expect(config.signal).not.toBe(externalController.signal) + + externalController.abort() + expect(config.signal?.aborted).toBe(true) + }) + + test("should abort merged signal when internal controller is aborted", () => { + const internalController = new AbortController() + const externalController = new AbortController() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal( + internalController, + makeCreateMessageMetadata({ abortSignal: externalController.signal }), + ) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal?.aborted).toBe(false) + + internalController.abort() + expect(config.signal?.aborted).toBe(true) + }) + + test("should abort merged signal after timeout elapses without manual cleanup", async () => { + const internalController = new AbortController() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal(internalController, undefined, 50) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).not.toBe(internalController.signal) + expect(config.signal?.aborted).toBe(false) + + await vi.waitFor(() => expect(config.signal?.aborted).toBe(true)) + }) + + test("should immediately abort when metadata signal is already aborted", () => { + const internalController = new AbortController() + const externalController = new AbortController() + externalController.abort() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal( + internalController, + makeCreateMessageMetadata({ abortSignal: externalController.signal }), + ) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal?.aborted).toBe(true) + }) + + test("should propagate abort from internal controller when all three sources are merged", () => { + const internalController = new AbortController() + const externalController = new AbortController() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal( + internalController, + makeCreateMessageMetadata({ abortSignal: externalController.signal }), + 10_000, + ) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal?.aborted).toBe(false) + + internalController.abort() + expect(config.signal?.aborted).toBe(true) + }) + }) + + describe("integration tests", () => { + test("should support full chain of operations", () => { + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + type TestOptions = { + modelId?: string + signal?: AbortSignal + headers?: Record + maxTokens?: number + } + + const builder = new RequestConfigBuilder({ modelId: "default-model" }) + builder.setAbortSignal(metadata) + builder.addHeaders({ "X-API-Key": "secret" }) + builder.setOption("maxTokens", 2000) + + const config = builder.build() as TestOptions + expect(config.modelId).toBe("default-model") + expect(config.signal).toBe(controller.signal) + expect(config.headers).toEqual({ "X-API-Key": "secret" }) + expect(config.maxTokens).toBe(2000) + }) + + test("should handle empty builder through full lifecycle", () => { + const builder = new RequestConfigBuilder() + expect(builder.build()).toBeUndefined() + expect(builder.getOption("anyKey")).toBeUndefined() + }) + + test("should work with custom default options type", () => { + type CustomOptions = { apiUrl: string; timeout: number; retryCount?: number } + + const defaults: Partial = { + apiUrl: "https://api.example.com", + timeout: 30000, + } + + const builder = new RequestConfigBuilder(defaults) + builder.setOption("retryCount", 3) + + const config = builder.build() as CustomOptions + expect(config.apiUrl).toBe("https://api.example.com") + expect(config.timeout).toBe(30000) + expect(config.retryCount).toBe(3) + }) + + test("should accept interface-based options without an index signature", () => { + interface SdkOptions { + modelId?: string + signal?: AbortSignal + headers?: Record + maxTokens?: number + } + + const builder = new RequestConfigBuilder({ modelId: "default-model" }) + builder.setOption("maxTokens", 2000) + + const config = builder.build() as SdkOptions + expect(config.modelId).toBe("default-model") + expect(config.maxTokens).toBe(2000) + }) + }) +}) diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index c685da0ed2..57feb39636 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -96,6 +96,10 @@ vitest.mock("../fetchers/modelCache", () => ({ }, }) }), + refreshModels: vitest.fn(async (options) => { + const { getModels } = await import("../fetchers/modelCache") + return getModels(options) + }), })) describe("RequestyHandler", () => { diff --git a/src/api/providers/__tests__/unbound.spec.ts b/src/api/providers/__tests__/unbound.spec.ts index 9b45713386..99f11d7b6f 100644 --- a/src/api/providers/__tests__/unbound.spec.ts +++ b/src/api/providers/__tests__/unbound.spec.ts @@ -32,6 +32,10 @@ vi.mock("../fetchers/modelCache", () => ({ description: "GPT-4o", }, }), + refreshModels: vi.fn(async (options) => { + const { getModels } = await import("../fetchers/modelCache") + return getModels(options) + }), })) describe("UnboundHandler", () => { diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index ffad3fa0d1..b238d72381 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -99,6 +99,10 @@ vitest.mock("../fetchers/modelCache", () => ({ }, }) }), + refreshModels: vitest.fn(async (options) => { + const { getModels } = await import("../fetchers/modelCache") + return getModels(options) + }), getModelsFromCache: vitest.fn().mockReturnValue(undefined), })) diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index ac13152f37..0230b679a9 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -11,6 +11,7 @@ import { internationalZAiModels, mainlandZAiModels, ZAI_DEFAULT_TEMPERATURE, + getZAiModels, } from "@roo-code/types" import { ZAiHandler } from "../zai" @@ -141,6 +142,31 @@ describe("ZAiHandler", () => { expect(model.info.cacheReadsPrice).toBe(0.26) }) + it("should expose GLM-5.3 for the international Coding Plan with official pricing", () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-5.3", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe("glm-5.3") + expect(model.info).toMatchObject({ + contextWindow: 1_000_000, + maxTokens: 131_072, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + supportsReasoningEffort: ["low", "high", "max"], + requiredReasoningEffort: true, + reasoningEffort: "max", + preserveReasoning: true, + defaultTemperature: 1, + }) + expect(model.info.inputPrice).toBe(1.4) + expect(model.info.outputPrice).toBe(4.4) + expect(model.info.cacheReadsPrice).toBe(0.26) + }) + it("should return GLM-5-Turbo international model with thinking support", () => { const testModelId: InternationalZAiModelId = "glm-5-turbo" const handlerWithModel = new ZAiHandler({ @@ -277,6 +303,22 @@ describe("ZAiHandler", () => { expect(model.info.cacheReadsPrice).toBe(0.13) }) + it("should expose GLM-5.3 for the China Coding Plan", () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-5.3", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "china_coding", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe("glm-5.3") + expect(model.info.supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(model.info.requiredReasoningEffort).toBe(true) + expect(model.info.reasoningEffort).toBe("max") + expect(model.info.inputPrice).toBe(0.68) + expect(model.info.outputPrice).toBe(2.28) + expect(model.info.cacheReadsPrice).toBe(0.13) + }) + it("should return GLM-4.7 China model with thinking support", () => { const testModelId: MainlandZAiModelId = "glm-4.7" const handlerWithModel = new ZAiHandler({ @@ -348,6 +390,16 @@ describe("ZAiHandler", () => { expect(model.id).toBe(testModelId) expect(model.info).toEqual(internationalZAiModels[testModelId]) }) + + it("should expose GLM-5.3 on the international API", () => { + expect(getZAiModels("international_api")).toHaveProperty("glm-5.3") + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-5.3", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_api", + }) + expect(handlerWithModel.getModel().id).toBe("glm-5.3") + }) }) describe("China API", () => { @@ -387,6 +439,10 @@ describe("ZAiHandler", () => { expect(model.id).toBe(testModelId) expect(model.info).toEqual(mainlandZAiModels[testModelId]) }) + + it("should not expose Coding Plan-only models", () => { + expect(getZAiModels("china_api")).not.toHaveProperty("glm-5.3") + }) }) describe("Default behavior", () => { @@ -613,6 +669,71 @@ describe("ZAiHandler", () => { ) }) + it("should use the official GLM-5.3 thinking and sampling defaults", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-5.3", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + reasoningEffort: "disable", + }) + + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + + const messageGenerator = handlerWithModel.createMessage("system prompt", []) + await messageGenerator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "glm-5.3", + thinking: { type: "enabled", clear_thinking: false }, + reasoning_effort: "max", + temperature: 1, + }), + ) + }) + + it("should keep GLM-5.3 reasoning enabled when the master reasoning setting is disabled", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-5.3", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + enableReasoningEffort: false, + }) + + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + + const messageGenerator = handlerWithModel.createMessage("system prompt", []) + await messageGenerator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "glm-5.3", + thinking: { type: "enabled", clear_thinking: false }, + reasoning_effort: "max", + }), + ) + }) + + it("should use the official GLM-5.3 parameters for completePrompt", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-5.3", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_api", + reasoningEffort: "low", + }) + + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + + await expect(handlerWithModel.completePrompt("prompt")).resolves.toBe("response") + expect(mockCreate).toHaveBeenCalledWith({ + model: "glm-5.3", + messages: [{ role: "user", content: "prompt" }], + temperature: 1, + thinking: { type: "enabled", clear_thinking: false }, + reasoning_effort: "low", + }) + }) + it("should omit reasoning_effort for GLM-5.2 when reasoningEffort is set to disable", async () => { const handlerWithModel = new ZAiHandler({ apiModelId: "glm-5.2", diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index 66131d7cb1..c6f4c15c1e 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -43,32 +43,37 @@ vitest.mock("delay", () => ({ return Promise.resolve() }), })) +const DEFAULT_MODEL_CATALOG = vitest.hoisted(() => ({ + "anthropic/claude-sonnet-4": { + maxTokens: 64000, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3, + outputPrice: 15, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + description: "Claude Sonnet 4", + }, + "anthropic/claude-3.5-haiku": { + maxTokens: 32000, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 1, + outputPrice: 5, + cacheWritesPrice: 1.25, + cacheReadsPrice: 0.1, + description: "Claude 3.5 Haiku", + }, +})) + vitest.mock("../fetchers/modelCache", () => ({ getModels: vitest.fn().mockImplementation(function () { - return Promise.resolve({ - "anthropic/claude-sonnet-4": { - maxTokens: 64000, - contextWindow: 200000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 3, - outputPrice: 15, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - description: "Claude Sonnet 4", - }, - "anthropic/claude-3.5-haiku": { - maxTokens: 32000, - contextWindow: 200000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 1, - outputPrice: 5, - cacheWritesPrice: 1.25, - cacheReadsPrice: 0.1, - description: "Claude 3.5 Haiku", - }, - }) + return Promise.resolve(DEFAULT_MODEL_CATALOG) + }), + refreshModels: vitest.fn().mockImplementation(function () { + return Promise.resolve(DEFAULT_MODEL_CATALOG) }), getModelsFromCache: vitest.fn().mockReturnValue(undefined), })) @@ -119,8 +124,22 @@ describe("ZooGatewayHandler", () => { zooGatewayModelId: "anthropic/claude-sonnet-4", } - beforeEach(() => { + beforeEach(async () => { clearAllMocks() + const { getModels, refreshModels, getModelsFromCache } = await import("../fetchers/modelCache") + vitest + .mocked(getModels) + .mockReset() + .mockImplementation(function () { + return Promise.resolve(DEFAULT_MODEL_CATALOG) + }) + vitest + .mocked(refreshModels) + .mockReset() + .mockImplementation(function () { + return Promise.resolve(DEFAULT_MODEL_CATALOG) + }) + vitest.mocked(getModelsFromCache).mockReset().mockReturnValue(undefined) mockSessionCleared.value = false mockGetCachedZooCodeToken.mockReturnValue(undefined) mockCreate.mockClear() @@ -277,6 +296,73 @@ describe("ZooGatewayHandler", () => { ]) }) + it("forwards gateway usage.cost as totalCost so the panel matches billing", async () => { + mockCreate.mockImplementation(async () => + asyncStreamFrom([ + { + choices: [{ delta: { content: "ok" }, index: 0 }], + usage: null, + }, + { + choices: [{ delta: {}, index: 0 }], + usage: { + prompt_tokens: 25694, + completion_tokens: 829, + total_tokens: 26523, + cost: 0.006262, + }, + }, + ]), + ) + + const handler = new ZooGatewayHandler(mockOptions) + const chunks = await collectStream( + handler.createMessage("You are helpful.", [{ role: "user", content: "Hello" }]), + ) + + expect(chunks).toContainEqual({ + type: "usage", + inputTokens: 25694, + outputTokens: 829, + cacheWriteTokens: undefined, + cacheReadTokens: undefined, + totalCost: 0.006262, + }) + }) + + it("defaults totalCost to 0 when usage omits cost", async () => { + mockCreate.mockImplementation(async () => + asyncStreamFrom([ + { + choices: [{ delta: { content: "ok" }, index: 0 }], + usage: null, + }, + { + choices: [{ delta: {}, index: 0 }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }, + }, + ]), + ) + + const handler = new ZooGatewayHandler(mockOptions) + const chunks = await collectStream( + handler.createMessage("You are helpful.", [{ role: "user", content: "Hello" }]), + ) + + expect(chunks).toContainEqual({ + type: "usage", + inputTokens: 10, + outputTokens: 5, + cacheWriteTokens: undefined, + cacheReadTokens: undefined, + totalCost: 0, + }) + }) + it("forwards task and mode metadata as request headers", async () => { const handler = new ZooGatewayHandler(mockOptions) @@ -628,45 +714,218 @@ describe("ZooGatewayHandler", () => { describe("ensureModelFetched", () => { it("fetches models when instance models are empty", async () => { const handler = new ZooGatewayHandler(mockOptions) - const { getModels } = await import("../fetchers/modelCache") + const { getModels, refreshModels } = await import("../fetchers/modelCache") expect(handler.getModel().info.contextWindow).toBe(200000) await handler.ensureModelFetched() expect(getModels).toHaveBeenCalled() + expect(refreshModels).not.toHaveBeenCalled() }) it("skips the fetch when models are already populated", async () => { const handler = new ZooGatewayHandler(mockOptions) - const { getModels } = await import("../fetchers/modelCache") + const { getModels, refreshModels } = await import("../fetchers/modelCache") await handler.ensureModelFetched() vitest.mocked(getModels).mockClear() + vitest.mocked(refreshModels).mockClear() await handler.ensureModelFetched() expect(getModels).not.toHaveBeenCalled() + expect(refreshModels).not.toHaveBeenCalled() }) it("short-circuits a subsequent fetchModel call after models are populated", async () => { const handler = new ZooGatewayHandler(mockOptions) - const { getModels } = await import("../fetchers/modelCache") + const { getModels, refreshModels } = await import("../fetchers/modelCache") await handler.ensureModelFetched() vitest.mocked(getModels).mockClear() + vitest.mocked(refreshModels).mockClear() await handler.fetchModel() expect(getModels).not.toHaveBeenCalled() + expect(refreshModels).not.toHaveBeenCalled() + }) + + it("refetches when the configured model is missing from a populated map", async () => { + vitest.useFakeTimers() + try { + const { getModels, refreshModels } = await import("../fetchers/modelCache") + const staleCatalog = { + "anthropic/claude-sonnet-4": { + maxTokens: 64000, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3, + outputPrice: 15, + }, + } + vitest.mocked(getModels).mockResolvedValueOnce(staleCatalog) + vitest.mocked(refreshModels).mockResolvedValueOnce(staleCatalog) + + const handler = new ZooGatewayHandler({ + ...mockOptions, + zooGatewayModelId: "alibaba/qwen3.8-max", + }) + + await handler.ensureModelFetched() + expect(handler.getModel().info.inputPrice).toBe(0) + + const freshCatalog = { + "alibaba/qwen3.8-max": { + maxTokens: 65536, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.222, + outputPrice: 0.667, + }, + } + vitest.mocked(getModels).mockClear() + vitest.mocked(refreshModels).mockClear() + vitest.mocked(getModels).mockResolvedValueOnce(freshCatalog) + + vitest.advanceTimersByTime(5 * 60 * 1000 + 1) + await handler.ensureModelFetched() + + expect(getModels).toHaveBeenCalled() + expect(refreshModels).not.toHaveBeenCalled() + expect(handler.getModel().info.inputPrice).toBe(0.222) + expect(handler.getModel().info.outputPrice).toBe(0.667) + } finally { + vitest.useRealTimers() + } + }) + + it("does not reuse default model prices for an unknown configured model", async () => { + const { getModels, refreshModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockResolvedValueOnce({}) + vitest.mocked(refreshModels).mockResolvedValueOnce({}) + + const handler = new ZooGatewayHandler({ + ...mockOptions, + zooGatewayModelId: "alibaba/qwen3.8-max", + }) + + await handler.ensureModelFetched() + + const { id, info } = handler.getModel() + expect(id).toBe("alibaba/qwen3.8-max") + expect(info.inputPrice).toBe(0) + expect(info.outputPrice).toBe(0) + expect(info.cacheWritesPrice).toBe(0) + expect(info.cacheReadsPrice).toBe(0) + }) + + it("does not refetch an unresolved model on every ensureModelFetched call", async () => { + const { getModels, refreshModels } = await import("../fetchers/modelCache") + const catalog = { + "anthropic/claude-sonnet-4": { + maxTokens: 64000, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3, + outputPrice: 15, + }, + } + vitest.mocked(getModels).mockResolvedValue(catalog) + vitest.mocked(refreshModels).mockResolvedValue(catalog) + + const handler = new ZooGatewayHandler({ + ...mockOptions, + zooGatewayModelId: "alibaba/qwen3.8-max", + }) + + await handler.ensureModelFetched() + vitest.mocked(getModels).mockClear() + vitest.mocked(refreshModels).mockClear() + + await handler.ensureModelFetched() + await handler.ensureModelFetched() + + expect(getModels).not.toHaveBeenCalled() + expect(refreshModels).not.toHaveBeenCalled() + expect(handler.getModel().info.inputPrice).toBe(0) + }) + + it("negative-caches an empty catalog response for a missing model", async () => { + const { getModels, refreshModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockResolvedValue({}) + vitest.mocked(refreshModels).mockResolvedValue({}) + + const handler = new ZooGatewayHandler({ + ...mockOptions, + zooGatewayModelId: "alibaba/qwen3.8-max", + }) + + await handler.ensureModelFetched() + vitest.mocked(getModels).mockClear() + vitest.mocked(refreshModels).mockClear() + + await handler.ensureModelFetched() + + expect(getModels).not.toHaveBeenCalled() + expect(refreshModels).not.toHaveBeenCalled() + expect(handler.getModel().info.inputPrice).toBe(0) + }) + + it("force-refreshes before negative-caching when shared catalog is stale", async () => { + const { getModels, refreshModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockResolvedValueOnce({ + "anthropic/claude-sonnet-4": { + maxTokens: 64000, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3, + outputPrice: 15, + }, + }) + vitest.mocked(refreshModels).mockResolvedValueOnce({ + "alibaba/qwen3.8-max": { + maxTokens: 65536, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.222, + outputPrice: 0.667, + }, + }) + + const handler = new ZooGatewayHandler({ + ...mockOptions, + zooGatewayModelId: "alibaba/qwen3.8-max", + }) + + await handler.ensureModelFetched() + + expect(getModels).toHaveBeenCalled() + expect(refreshModels).toHaveBeenCalled() + expect(handler.getModel().info.inputPrice).toBe(0.222) + expect(handler.getModel().info.outputPrice).toBe(0.667) + + vitest.mocked(getModels).mockClear() + vitest.mocked(refreshModels).mockClear() + await handler.ensureModelFetched() + expect(getModels).not.toHaveBeenCalled() + expect(refreshModels).not.toHaveBeenCalled() }) it("deduplicates concurrent calls into a single fetch", async () => { const handler = new ZooGatewayHandler(mockOptions) - const { getModels } = await import("../fetchers/modelCache") + const { getModels, refreshModels } = await import("../fetchers/modelCache") vitest.mocked(getModels).mockClear() + vitest.mocked(refreshModels).mockClear() await Promise.all([handler.ensureModelFetched(), handler.ensureModelFetched()]) expect(getModels).toHaveBeenCalledTimes(1) + expect(refreshModels).not.toHaveBeenCalled() }) it("recovers after a rejected fetch so later calls are not poisoned", async () => { @@ -690,7 +949,7 @@ describe("ZooGatewayHandler", () => { }) it("makes getModel return the fetched context window instead of the default", async () => { - const { getModels } = await import("../fetchers/modelCache") + const { getModels, refreshModels } = await import("../fetchers/modelCache") vitest.mocked(getModels).mockResolvedValueOnce({ "google/gemini-2.5-pro": { maxTokens: 65536, @@ -710,6 +969,7 @@ describe("ZooGatewayHandler", () => { await handler.ensureModelFetched() expect(handler.getModel().info.contextWindow).toBe(1048576) + expect(refreshModels).not.toHaveBeenCalled() }) }) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 9d91b25fe5..7b72b1100b 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -24,6 +24,7 @@ import { } from "../../core/prompts/tools/native-tools/converters" import { BaseProvider } from "./base-provider" +import { NOT_PROVIDED } from "./constants" import { parseVertexJsonCredentials } from "./utils/vertex-credentials" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" @@ -38,7 +39,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple this.options = options // https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions - const projectId = this.options.vertexProjectId ?? "not-provided" + const projectId = this.options.vertexProjectId ?? NOT_PROVIDED const region = this.options.vertexRegion ?? "us-east5" const parsedVertexCredentials = parseVertexJsonCredentials(this.options.vertexJsonCredentials) diff --git a/src/api/providers/config-builder/request-config-builder.ts b/src/api/providers/config-builder/request-config-builder.ts new file mode 100644 index 0000000000..2201d735bc --- /dev/null +++ b/src/api/providers/config-builder/request-config-builder.ts @@ -0,0 +1,166 @@ +import type { ApiHandlerCreateMessageMetadata } from "../../index" +import { mergeAbortSignalAndTimeout, mergeAbortSignals } from "../utils/abort-signal" + +/** + * A generic, SDK-agnostic request configuration builder. + * + * Provides a fluent API for building request configurations with: + * - Chainable method calls + * - Generic type support (TOptions) + * - Abort signal handling + * - Header merging + * - Static factory methods + */ +type RequestConfigOptionsBase = object & { + headers?: Record + signal?: AbortSignal +} + +type RequestConfigOptions = RequestConfigOptionsBase & Record + +export class RequestConfigBuilder { + protected options: Partial + + constructor(defaultOptions?: Partial) { + if (!defaultOptions) { + this.options = {} + return + } + + const defined = Object.fromEntries( + Object.entries(defaultOptions).filter(([, value]) => value !== undefined), + ) as Partial + + // Own the headers object so later mutations of the caller's defaults do not leak in. + if (defined.headers) { + defined.headers = { ...defined.headers } + } + + this.options = defined + } + + /** + * Set the abort signal from metadata, replacing any previously configured + * signal (including one created by addMergedSignal). Use addMergedSignal to + * combine signals instead of overwriting them. + * + * @param metadata - Optional metadata containing an abortSignal + * @returns this for chainable calls + */ + setAbortSignal(metadata?: ApiHandlerCreateMessageMetadata): this { + if (!metadata?.abortSignal) { + return this + } + + this.options = { ...this.options, signal: metadata.abortSignal } + return this + } + + /** + * Add or merge custom headers. + * + * @param headers - Key-value pairs of header names and values + * @returns this for chainable calls + */ + addHeaders(headers?: Record): this { + if (!headers || Object.keys(headers).length === 0) { + return this + } + + const existingHeaders = this.options.headers ?? {} + this.options = { ...this.options, headers: { ...existingHeaders, ...headers } } + return this + } + + /** + * Merge an internal controller signal with an external metadata signal and optional timeout. + * + * Use this for providers that already maintain their own AbortController but also need + * to honor the request-level abort signal from metadata and/or a timeout. The timeout is + * created via the native AbortSignal.timeout() API, which self-manages its timer — no + * manual cleanup is required. + * + * @param internalController - Provider-owned AbortController for the current request + * @param metadata - Optional metadata containing an external abortSignal + * @param timeoutMs - Optional positive timeout in milliseconds; <= 0 disables timeout + * @returns this for chainable calls + */ + addMergedSignal( + internalController: AbortController, + metadata?: ApiHandlerCreateMessageMetadata, + timeoutMs?: number, + ): this { + const merged = mergeAbortSignalAndTimeout(metadata?.abortSignal, timeoutMs) + const signal = mergeAbortSignals(internalController.signal, merged) + + this.options = { ...this.options, signal } + return this + } + + /** + * Set a single option by key (type-safe). + * + * @param key - Option key + * @param value - Option value + * @returns this for chainable calls + */ + setOption(key: K, value: TOptions[K]): this { + if (value === undefined) { + return this + } + + this.options = { ...this.options, [key]: value } + return this + } + + /** + * Get an option by key. + * + * @param key - Option key + * @returns The option value or undefined if not set + */ + getOption(key: K): TOptions[K] | undefined { + return this.options[key] + } + + /** + * Build the final configuration object. + * + * Copies the top-level options and the nested headers object, so mutating the + * result does not change builder state. The abort signal is a live object and + * is shared by reference on purpose. Other nested option values are not cloned. + * Returns undefined if no options have been set. + * + * @returns A partial built configuration (only the options that were set) or + * undefined if empty + */ + build(): Partial | undefined { + const keys = Object.keys(this.options as object) + if (keys.length === 0) { + return undefined + } + + const result = { ...this.options } + if (result.headers) { + result.headers = { ...result.headers } + } + + return result + } + + /** + * Factory method to quickly create and configure a builder from metadata. + * + * @param metadata - Optional metadata containing an abortSignal + * @param extraOptions - Additional options to merge + * @returns The built configuration or undefined if empty + */ + static fromMetadata( + metadata?: ApiHandlerCreateMessageMetadata, + extraOptions?: Partial, + ): Partial | undefined { + const builder = new RequestConfigBuilder(extraOptions) + builder.setAbortSignal(metadata) + return builder.build() + } +} diff --git a/src/api/providers/constants.ts b/src/api/providers/constants.ts index 2269ce9b6c..e3491321e9 100644 --- a/src/api/providers/constants.ts +++ b/src/api/providers/constants.ts @@ -5,3 +5,5 @@ export const DEFAULT_HEADERS = { "X-Title": "Zoo Code", "User-Agent": `ZooCode/${Package.version}`, } + +export const NOT_PROVIDED = "not-provided" diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 2e85c016b0..64782c9fb4 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -17,6 +17,7 @@ import { getModelParams } from "../transform/model-params" import { convertToR1Format } from "../transform/r1-format" import { OpenAiHandler } from "./openai" +import { NOT_PROVIDED } from "./constants" import { extractReasoningFromDelta } from "./utils/extract-reasoning" import type { ApiHandlerCreateMessageMetadata } from "../index" import { handleOpenAIError } from "./utils/error-handler" @@ -46,38 +47,22 @@ export const normalizeDeepSeekReasoningEffort = ( modelId: DeepSeekModelId, reasoningEffort?: string, ): "low" | "high" | "max" | undefined => { + // still check the modelId so non-supported models won't produce reasoning efforts switch (modelId) { case "deepseek-v4-flash": + case "deepseek-v4-pro": switch (reasoningEffort) { case "low": return "low" + case "medium": case "high": - return "high" - case "xhigh": return "high" case "max": return "max" } - break - - case "deepseek-v4-pro": - switch (reasoningEffort) { - case "low": - return "high" - - case "high": - return "high" - - case "xhigh": - return "max" - - case "max": - return "max" - } - break } return undefined @@ -100,7 +85,7 @@ export class DeepSeekHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { super({ ...options, - openAiApiKey: options.deepSeekApiKey ?? "not-provided", + openAiApiKey: options.deepSeekApiKey ?? NOT_PROVIDED, openAiModelId: options.apiModelId ?? deepSeekDefaultModelId, openAiBaseUrl: options.deepSeekBaseUrl || "https://api.deepseek.com", openAiStreamingEnabled: true, diff --git a/src/api/providers/fetchers/__tests__/litellm.spec.ts b/src/api/providers/fetchers/__tests__/litellm.spec.ts index 8e6d49ddae..9f7b80cb31 100644 --- a/src/api/providers/fetchers/__tests__/litellm.spec.ts +++ b/src/api/providers/fetchers/__tests__/litellm.spec.ts @@ -765,7 +765,7 @@ describe("getLiteLLMModels", () => { data: { data: [ { - model_name: "glm-5.2", + model_name: "glm-5.3", model_info: { max_tokens: 8192, max_input_tokens: 128000, @@ -782,7 +782,7 @@ describe("getLiteLLMModels", () => { const result = await getLiteLLMModels("test-api-key", "http://localhost:4000") - expect(result["glm-5.2"]).toMatchObject({ preserveReasoning: true }) + expect(result["glm-5.3"]).toMatchObject({ preserveReasoning: true }) }) it("does not match a model id that merely contains a known family as a substring", async () => { diff --git a/src/api/providers/fetchers/__tests__/lmstudio.test.ts b/src/api/providers/fetchers/__tests__/lmstudio.test.ts index bf7b57cdbd..789b57096f 100644 --- a/src/api/providers/fetchers/__tests__/lmstudio.test.ts +++ b/src/api/providers/fetchers/__tests__/lmstudio.test.ts @@ -1,9 +1,16 @@ import axios from "axios" import { LMStudioClient, LLMInstanceInfo, LLMInfo } from "@lmstudio/sdk" -import { ModelInfo, lMStudioDefaultModelInfo } from "@roo-code/types" +import { ModelInfo, lMStudioDefaultModelInfo, providerIdentifiers } from "@roo-code/types" -import { getLMStudioModels, parseLMStudioModel } from "../lmstudio" +import { forceFullModelDetailsLoad, getLMStudioModels, hasLoadedFullDetails, parseLMStudioModel } from "../lmstudio" + +const mockFlushModels = vi.hoisted(() => vi.fn()) + +vi.mock("../modelCache", () => ({ + flushModels: mockFlushModels, + getModels: vi.fn(), +})) // Mock axios vi.mock("axios") @@ -13,12 +20,14 @@ const mockedAxios = axios as any const mockGetModelInfo = vi.fn() const mockListLoaded = vi.fn() const mockListDownloadedModels = vi.fn() +const mockLoadModel = vi.fn() vi.mock("@lmstudio/sdk", () => { return { LMStudioClient: vi.fn().mockImplementation(function () { return { llm: { listLoaded: mockListLoaded, + model: mockLoadModel, }, system: { listDownloadedModels: mockListDownloadedModels, @@ -36,6 +45,30 @@ describe("LMStudio Fetcher", () => { mockListLoaded.mockClear() mockGetModelInfo.mockClear() mockListDownloadedModels.mockClear() + mockLoadModel.mockClear() + mockFlushModels.mockClear() + }) + + describe("forceFullModelDetailsLoad", () => { + it("loads the selected model before refreshing its server-scoped cache and recording full details", async () => { + const baseUrl = "https://securehost:4321" + const modelId = "mistralai/devstral-small-2505" + await getLMStudioModels("not a valid URL") + vi.clearAllMocks() + mockedAxios.get.mockResolvedValueOnce({ data: { status: "ok" } }) + mockLoadModel.mockResolvedValueOnce({}) + mockFlushModels.mockResolvedValueOnce(undefined) + + expect(hasLoadedFullDetails(modelId)).toBe(false) + + await forceFullModelDetailsLoad(baseUrl, modelId) + + expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`) + expect(MockedLMStudioClientConstructor).toHaveBeenCalledWith({ baseUrl: "wss://securehost:4321" }) + expect(mockLoadModel).toHaveBeenCalledWith(modelId) + expect(mockFlushModels).toHaveBeenCalledWith({ provider: providerIdentifiers.lmstudio, baseUrl }, true) + expect(hasLoadedFullDetails(modelId)).toBe(true) + }) }) describe("parseLMStudioModel", () => { diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 512cbdb9c6..108aa1827b 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -45,6 +45,7 @@ vi.mock("../litellm") vi.mock("../openrouter") vi.mock("../requesty") vi.mock("../kenari") +vi.mock("../nanogpt") vi.mock("../moonshot") vi.mock("../zoo-gateway") @@ -70,6 +71,7 @@ import { getLiteLLMModels } from "../litellm" import { getOpenRouterModels } from "../openrouter" import { getRequestyModels } from "../requesty" import { getKenariModels } from "../kenari" +import { getNanoGptModels } from "../nanogpt" import { getMoonshotModels } from "../moonshot" import { getZooGatewayModels } from "../zoo-gateway" @@ -77,6 +79,7 @@ const mockGetLiteLLMModels = getLiteLLMModels as Mock const mockGetOpenRouterModels = getOpenRouterModels as Mock const mockGetRequestyModels = getRequestyModels as Mock const mockGetKenariModels = getKenariModels as Mock +const mockGetNanoGptModels = getNanoGptModels as Mock const mockGetMoonshotModels = getMoonshotModels as Mock const mockGetZooGatewayModels = getZooGatewayModels as Mock @@ -197,6 +200,22 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("dispatches NanoGPT with an optional API key", async () => { + const mockModels = { + "openai/gpt-5.6-sol": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + }, + } + mockGetNanoGptModels.mockResolvedValue(mockModels) + + const result = await getModels({ provider: providerIdentifiers.nanogpt, apiKey: "nanogpt-key" }) + + expect(mockGetNanoGptModels).toHaveBeenCalledWith("nanogpt-key") + expect(result).toEqual(mockModels) + }) + it("handles errors and re-throws them", async () => { const expectedError = new Error("LiteLLM connection failed") mockGetLiteLLMModels.mockRejectedValue(expectedError) @@ -1079,6 +1098,31 @@ describe("key-scoped cache key derivation", () => { }) }) +describe("NanoGPT key-scoped cache isolation", () => { + const nanoGptModels = { + "openai/gpt-5.6-sol": { maxTokens: 128000, contextWindow: 1050000, supportsPromptCache: false }, + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetNanoGptModels.mockResolvedValue(nanoGptModels) + }) + + it("separates public, key A, and key B cache identities without exposing raw keys", async () => { + const mockCache = vi.mocked(new (vi.mocked(NodeCache))()) + mockCache.get.mockReturnValue(undefined) + + await getModels({ provider: providerIdentifiers.nanogpt }) + await getModels({ provider: providerIdentifiers.nanogpt, apiKey: "nano-key-a" }) + await getModels({ provider: providerIdentifiers.nanogpt, apiKey: "nano-key-b" }) + + const cacheKeys = mockCache.set.mock.calls.map(([key]) => key as string) + expect(new Set(cacheKeys).size).toBe(3) + expect(cacheKeys).toContain("nanogpt") + expect(cacheKeys.every((key) => !key.includes("nano-key-a") && !key.includes("nano-key-b"))).toBe(true) + }) +}) + describe("compound cache key derivation across scoping dimensions", () => { // Exercises every branch of getCacheKey via the public getModels() entry point. // litellm is url-scoped AND key-scoped; openrouter is neither, so it hits the bare diff --git a/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts b/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts index b5ff897ec4..300772617b 100644 --- a/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts @@ -1,6 +1,9 @@ // npx vitest run api/providers/fetchers/__tests__/modelEndpointCache.spec.ts import { vi, describe, it, expect, beforeEach } from "vitest" + +import { providerIdentifiers } from "@roo-code/types" + import { getModelEndpoints } from "../modelEndpointCache" import * as modelCache from "../modelCache" import * as openrouter from "../openrouter" @@ -54,7 +57,7 @@ describe("modelEndpointCache", () => { vi.spyOn(openrouter, "getOpenRouterModelEndpoints").mockResolvedValue(mockEndpoints as any) const result = await getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: "anthropic/claude-sonnet-4", endpoint: "anthropic", }) @@ -94,7 +97,7 @@ describe("modelEndpointCache", () => { vi.spyOn(openrouter, "getOpenRouterModelEndpoints").mockResolvedValue(mockEndpoints as any) const result = await getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: "test/model", endpoint: "endpoint-1", }) @@ -122,7 +125,7 @@ describe("modelEndpointCache", () => { vi.spyOn(openrouter, "getOpenRouterModelEndpoints").mockResolvedValue(mockEndpoints as any) const result = await getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: "missing/model", endpoint: "anthropic", }) @@ -134,7 +137,7 @@ describe("modelEndpointCache", () => { it("should return empty object for non-openrouter providers", async () => { const result = await getModelEndpoints({ - router: "vercel-ai-gateway", + router: providerIdentifiers.vercelAiGateway, modelId: "claude-sonnet-4", endpoint: "default", }) @@ -144,13 +147,13 @@ describe("modelEndpointCache", () => { it("should return empty object when modelId or endpoint is missing", async () => { const result1 = await getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: undefined, endpoint: "anthropic", }) const result2 = await getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: "anthropic/claude-sonnet-4", endpoint: undefined, }) diff --git a/src/api/providers/fetchers/__tests__/nanogpt.spec.ts b/src/api/providers/fetchers/__tests__/nanogpt.spec.ts new file mode 100644 index 0000000000..e4673f5a1b --- /dev/null +++ b/src/api/providers/fetchers/__tests__/nanogpt.spec.ts @@ -0,0 +1,192 @@ +import axios from "axios" + +import { NANOGPT_BASE_URL, nanoGptDefaultModelInfo } from "@roo-code/types" + +import { getNanoGptModels, parseNanoGptModel } from "../nanogpt" + +vi.mock("axios") + +describe("NanoGPT model fetcher", () => { + beforeEach(() => vi.clearAllMocks()) + + it("requests the detailed catalog with optional Bearer authorization", async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { data: [] } }) + await getNanoGptModels("key-a") + expect(axios.get).toHaveBeenCalledWith(`${NANOGPT_BASE_URL}/models?detailed=true`, { + headers: { Authorization: "Bearer key-a" }, + timeout: 10_000, + }) + }) + + it("supports unauthenticated catalog requests", async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { data: [] } }) + await getNanoGptModels() + expect(axios.get).toHaveBeenCalledWith(`${NANOGPT_BASE_URL}/models?detailed=true`, { + headers: undefined, + timeout: 10_000, + }) + }) + + it("maps detailed metadata and exact per-million pricing for multiple models", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + unknown_top_level: true, + data: [ + { + id: "vision-model", + name: "Vision Model", + description: "Detailed description", + context_length: 1_050_000, + max_output_tokens: 128_000, + capabilities: { vision: true, tool_calling: true, unknown: "allowed" }, + pricing: { + prompt: 2.5, + completion: 10, + cacheReadInputPer1kTokens: 0.001, + cacheWriteInputPer1kTokens: 0.002, + unknown: 1, + }, + unknown: "allowed", + }, + { id: "text-model", capabilities: { vision: false } }, + ], + }, + }) + + const models = await getNanoGptModels() + expect(Object.keys(models)).toEqual(["vision-model", "text-model"]) + expect(models["vision-model"]).toEqual({ + contextWindow: 1_050_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsImages: true, + displayName: "Vision Model", + description: "Detailed description", + inputPrice: 2.5, + outputPrice: 10, + cacheReadsPrice: 1, + cacheWritesPrice: 2, + }) + expect(models["text-model"].supportsImages).toBe(false) + }) + + it("skips malformed records and models explicitly lacking tool calling", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + data: [ + { id: "eligible" }, + { missing: "id" }, + { id: "chat-only", capabilities: { tool_calling: false } }, + ], + }, + }) + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined) + expect(await getNanoGptModels()).toEqual({ + eligible: { + contextWindow: nanoGptDefaultModelInfo.contextWindow, + maxTokens: nanoGptDefaultModelInfo.maxTokens, + supportsPromptCache: false, + }, + }) + expect(warning).toHaveBeenCalledOnce() + warning.mockRestore() + }) + + it("keeps models with null token metadata and preserves reasoning capability", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + data: [ + { + id: "reasoning-model", + context_length: null, + max_output_tokens: null, + capabilities: { reasoning: true }, + }, + { id: "non-reasoning-model", capabilities: { reasoning: false } }, + ], + }, + }) + + const models = await getNanoGptModels() + expect(models["reasoning-model"]).toEqual({ + contextWindow: nanoGptDefaultModelInfo.contextWindow, + maxTokens: nanoGptDefaultModelInfo.maxTokens, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "medium", "high"], + }) + expect(models["non-reasoning-model"].supportsReasoningEffort).toBe(false) + }) + + it("preserves exact reasoning efforts and falls back when the catalog omits them", () => { + expect( + parseNanoGptModel({ + id: "high-only", + capabilities: { reasoning: true }, + reasoning_efforts: ["high"], + }).supportsReasoningEffort, + ).toEqual(["high"]) + + const extendedEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const + expect( + parseNanoGptModel({ + id: "extended-reasoning", + capabilities: { reasoning: true }, + reasoning_efforts: [...extendedEfforts], + }).supportsReasoningEffort, + ).toEqual(extendedEfforts) + + expect( + parseNanoGptModel({ id: "fallback-reasoning", capabilities: { reasoning: true } }).supportsReasoningEffort, + ).toEqual(["low", "medium", "high"]) + }) + + it.each([{ data: null }, [], null])("returns no models for invalid top-level data %#", async (data) => { + vi.mocked(axios.get).mockResolvedValue({ data }) + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined) + expect(await getNanoGptModels()).toEqual({}) + warning.mockRestore() + }) + + it.each([new Error("network unavailable"), "network unavailable"])( + "returns no models on network failure", + async (error) => { + vi.mocked(axios.get).mockRejectedValue(error) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined) + expect(await getNanoGptModels()).toEqual({}) + consoleError.mockRestore() + }, + ) + + it("rejects negative numeric metadata without leaking the API key in errors", async () => { + vi.mocked(axios.get) + .mockResolvedValueOnce({ + data: { + data: [ + { id: "valid-free", pricing: { prompt: 0, completion: 0 } }, + { id: "invalid-price", pricing: { prompt: -1 } }, + ], + }, + }) + .mockRejectedValueOnce(new Error("upstream rejected secret-key")) + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined) + + expect(await getNanoGptModels("secret-key")).toEqual({ + "valid-free": expect.objectContaining({ inputPrice: 0, outputPrice: 0 }), + }) + expect(await getNanoGptModels("secret-key")).toEqual({}) + expect(consoleError).toHaveBeenLastCalledWith("Error fetching NanoGPT models: upstream rejected [REDACTED]") + + warning.mockRestore() + consoleError.mockRestore() + }) + + it("does not invent absent optional metadata", () => { + const info = parseNanoGptModel({ id: "minimal" }) + expect(info).toEqual({ + contextWindow: nanoGptDefaultModelInfo.contextWindow, + maxTokens: nanoGptDefaultModelInfo.maxTokens, + supportsPromptCache: false, + }) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/opencode-go.spec.ts b/src/api/providers/fetchers/__tests__/opencode-go.spec.ts index c8607db4d3..20040ffb22 100644 --- a/src/api/providers/fetchers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/fetchers/__tests__/opencode-go.spec.ts @@ -157,6 +157,20 @@ describe("Opencode Go Fetchers", () => { expect(info.outputPrice).toBe(4.4) }) + it("resolves GLM-5.3 with always-on Low/High/Max reasoning effort", () => { + const info = parseOpencodeGoModel({ id: "glm-5.3" }) + expect(info.contextWindow).toBe(1_000_000) + expect(info.maxTokens).toBe(131_072) + expect(info.supportsPromptCache).toBe(true) + expect(info.supportsMaxTokens).toBe(true) + expect(info.supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(info.reasoningEffort).toBe("max") + expect(info.preserveReasoning).toBe(true) + expect(info.inputPrice).toBe(1.4) + expect(info.outputPrice).toBe(4.4) + expect(info.cacheReadsPrice).toBe(0.26) + }) + it("falls back to defaults for an unknown model with no cache pricing", () => { const info = parseOpencodeGoModel({ id: "x", context_window: 100000, max_tokens: 8000 }) expect(info.supportsPromptCache).toBe(false) diff --git a/src/api/providers/fetchers/lmstudio.ts b/src/api/providers/fetchers/lmstudio.ts index 73cb60e88e..842fe9d08d 100644 --- a/src/api/providers/fetchers/lmstudio.ts +++ b/src/api/providers/fetchers/lmstudio.ts @@ -1,7 +1,7 @@ import axios from "axios" import { LLM, LLMInfo, LLMInstanceInfo, LMStudioClient } from "@lmstudio/sdk" -import { type ModelInfo, lMStudioDefaultModelInfo } from "@roo-code/types" +import { type ModelInfo, lMStudioDefaultModelInfo, providerIdentifiers } from "@roo-code/types" import { flushModels, getModels } from "./modelCache" @@ -19,7 +19,7 @@ export const forceFullModelDetailsLoad = async (baseUrl: string, modelId: string const client = new LMStudioClient({ baseUrl: lmsUrl }) await client.llm.model(modelId) // Flush and refresh cache to get updated model details - await flushModels({ provider: "lmstudio", baseUrl }, true) + await flushModels({ provider: providerIdentifiers.lmstudio, baseUrl }, true) // Mark this model as having full details loaded. modelsWithLoadedDetails.add(modelId) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 6ef68864c1..50dbe12f6e 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -21,6 +21,7 @@ import { getOpenRouterModels } from "./openrouter" import { getVercelAiGatewayModels } from "./vercel-ai-gateway" import { getOpencodeGoModels } from "./opencode-go" import { getKenariModels } from "./kenari" +import { getNanoGptModels } from "./nanogpt" import { getRequestyModels } from "./requesty" import { getUnboundModels } from "./unbound" import { getLiteLLMModels } from "./litellm" @@ -95,6 +96,7 @@ const KEY_SCOPED_PROVIDERS: ReadonlySet = new Set([ providerIdentifiers.moonshot, // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn) providerIdentifiers.zooGateway, // Per-session-token account identity providerIdentifiers.kimiCode, // Per-session-token account identity + providerIdentifiers.nanogpt, // Public catalog can still vary by API-key allowlist ]) // Providers whose model lists are scoped to the signed-in user (e.g. per-account @@ -253,6 +255,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { provider: providerIdentifiers.vercelAiGateway, options: { provider: providerIdentifiers.vercelAiGateway }, }, + { + provider: providerIdentifiers.nanogpt, + options: { provider: providerIdentifiers.nanogpt }, + }, ] // Refresh each provider in background (fire and forget) diff --git a/src/api/providers/fetchers/modelEndpointCache.ts b/src/api/providers/fetchers/modelEndpointCache.ts index 06d6234f91..4e85213a6c 100644 --- a/src/api/providers/fetchers/modelEndpointCache.ts +++ b/src/api/providers/fetchers/modelEndpointCache.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import NodeCache from "node-cache" import sanitize from "sanitize-filename" -import type { ModelRecord } from "@roo-code/types" +import { providerIdentifiers, type ModelRecord } from "@roo-code/types" import { ContextProxy } from "../../../core/config/ContextProxy" import { RouterName } from "../../../shared/api" @@ -44,7 +44,7 @@ export const getModelEndpoints = async ({ }): Promise => { // OpenRouter is the only provider that supports model endpoints, but you // can see how we'd extend this to other providers in the future. - if (router !== "openrouter" || !modelId || !endpoint) { + if (router !== providerIdentifiers.openrouter || !modelId || !endpoint) { return {} } @@ -61,7 +61,7 @@ export const getModelEndpoints = async ({ // Copy model-level capabilities from the parent model to each endpoint // These are capabilities that don't vary by provider (tools, reasoning, etc.) if (Object.keys(modelProviders).length > 0) { - const parentModels = await getModels({ provider: "openrouter" }) + const parentModels = await getModels({ provider: providerIdentifiers.openrouter }) const parentModel = parentModels[modelId] if (parentModel) { diff --git a/src/api/providers/fetchers/nanogpt.ts b/src/api/providers/fetchers/nanogpt.ts new file mode 100644 index 0000000000..407f2ca85a --- /dev/null +++ b/src/api/providers/fetchers/nanogpt.ts @@ -0,0 +1,103 @@ +import axios from "axios" +import { z } from "zod" + +import { NANOGPT_BASE_URL, nanoGptDefaultModelInfo, type ModelInfo, type ModelRecord } from "@roo-code/types" + +const nanoGptReasoningEfforts: NonNullable = ["low", "medium", "high"] +const nanoGptReasoningEffortSchema = z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]) + +const nanoGptPricingSchema = z.object({ + prompt: z.number().nonnegative().optional(), + completion: z.number().nonnegative().optional(), + cacheReadInputPer1kTokens: z.number().nonnegative().optional(), + cacheWriteInputPer1kTokens: z.number().nonnegative().optional(), +}) + +const nanoGptModelSchema = z.object({ + id: z.string().min(1), + name: z.string().optional(), + description: z.string().optional(), + context_length: z.number().positive().nullish(), + max_output_tokens: z.number().positive().nullish(), + reasoning_efforts: z.array(nanoGptReasoningEffortSchema).optional(), + capabilities: z + .object({ + vision: z.boolean().optional(), + tool_calling: z.boolean().optional(), + reasoning: z.boolean().optional(), + }) + .optional(), + pricing: nanoGptPricingSchema.optional(), +}) + +export type NanoGptModel = z.infer + +function getSafeErrorMessage(error: unknown, apiKey?: string): string { + const message = error instanceof Error ? error.message : String(error) + return apiKey ? message.replaceAll(apiKey, "[REDACTED]") : message +} + +const nanoGptModelsResponseSchema = z.object({ + data: z.array(z.unknown()), +}) + +export const parseNanoGptModel = (model: NanoGptModel): ModelInfo => ({ + contextWindow: model.context_length ?? nanoGptDefaultModelInfo.contextWindow, + maxTokens: model.max_output_tokens ?? nanoGptDefaultModelInfo.maxTokens, + supportsPromptCache: false, + ...(model.capabilities?.vision !== undefined ? { supportsImages: model.capabilities.vision } : {}), + ...(model.capabilities?.reasoning !== undefined + ? { + supportsReasoningEffort: model.capabilities.reasoning + ? (model.reasoning_efforts ?? [...nanoGptReasoningEfforts]) + : false, + } + : {}), + ...(model.name !== undefined ? { displayName: model.name } : {}), + ...(model.description !== undefined ? { description: model.description } : {}), + ...(model.pricing?.prompt !== undefined ? { inputPrice: model.pricing.prompt } : {}), + ...(model.pricing?.completion !== undefined ? { outputPrice: model.pricing.completion } : {}), + ...(model.pricing?.cacheReadInputPer1kTokens !== undefined + ? { cacheReadsPrice: model.pricing.cacheReadInputPer1kTokens * 1_000 } + : {}), + ...(model.pricing?.cacheWriteInputPer1kTokens !== undefined + ? { cacheWritesPrice: model.pricing.cacheWriteInputPer1kTokens * 1_000 } + : {}), +}) + +/** Fetches NanoGPT's public detailed catalog, optionally scoped by a Bearer key. */ +export async function getNanoGptModels(apiKey?: string): Promise { + try { + const response = await axios.get(`${NANOGPT_BASE_URL}/models?detailed=true`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, + timeout: 10_000, + }) + const responseResult = nanoGptModelsResponseSchema.safeParse(response.data) + if (!responseResult.success) { + console.warn("NanoGPT models response did not match the expected top-level schema") + return {} + } + + const models: ModelRecord = {} + for (const rawModel of responseResult.data.data) { + const modelResult = nanoGptModelSchema.safeParse(rawModel) + if (!modelResult.success) { + console.warn("Skipping invalid NanoGPT model entry") + continue + } + + // NanoGPT can route to non-agentic models. An explicit false is authoritative; + // an omitted capability remains unknown and therefore eligible. + if (modelResult.data.capabilities?.tool_calling === false) { + continue + } + + models[modelResult.data.id] = parseNanoGptModel(modelResult.data) + } + + return models + } catch (error) { + console.error(`Error fetching NanoGPT models: ${getSafeErrorMessage(error, apiKey)}`) + return {} + } +} diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 44065d8801..ec0d14e4c9 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -25,6 +25,7 @@ import { getModelParams } from "../transform/model-params" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { BaseProvider } from "./base-provider" +import { NOT_PROVIDED } from "./constants" import { parseVertexJsonCredentials } from "./utils/vertex-credentials" type GeminiHandlerOptions = ApiHandlerOptions & { @@ -184,9 +185,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl this.options = options - const project = this.options.vertexProjectId ?? "not-provided" - const location = this.options.vertexRegion ?? "not-provided" - const apiKey = this.options.geminiApiKey ?? "not-provided" + const project = this.options.vertexProjectId ?? NOT_PROVIDED + const location = this.options.vertexRegion ?? NOT_PROVIDED + const apiKey = this.options.geminiApiKey ?? NOT_PROVIDED const parsedVertexCredentials = parseVertexJsonCredentials(this.options.vertexJsonCredentials) diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 5bdd7c8deb..8ba1eae382 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -1,3 +1,4 @@ +export { RequestConfigBuilder } from "./config-builder/request-config-builder" export { AnthropicVertexHandler } from "./anthropic-vertex" export { AnthropicHandler } from "./anthropic" export { AwsBedrockHandler } from "./bedrock" @@ -29,6 +30,7 @@ export { FriendliHandler } from "./friendli" export { VercelAiGatewayHandler } from "./vercel-ai-gateway" export { OpencodeGoHandler } from "./opencode-go" export { KenariHandler } from "./kenari" +export { NanoGptHandler } from "./nanogpt" export { ZooGatewayHandler } from "./zoo-gateway" export { MiniMaxHandler } from "./minimax" export { MimoHandler } from "./mimo" diff --git a/src/api/providers/kenari.ts b/src/api/providers/kenari.ts index 7895a9452c..a6ad643ec3 100644 --- a/src/api/providers/kenari.ts +++ b/src/api/providers/kenari.ts @@ -6,6 +6,7 @@ import { kenariDefaultModelInfo, KENARI_DEFAULT_TEMPERATURE, KENARI_BASE_URL, + providerIdentifiers, } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" @@ -37,7 +38,7 @@ export class KenariHandler extends RouterProvider implements SingleCompletionHan constructor(options: ApiHandlerOptions) { super({ options, - name: "kenari", + name: providerIdentifiers.kenari, baseURL: KENARI_BASE_URL, apiKey: options.kenariApiKey, modelId: options.kenariModelId, diff --git a/src/api/providers/kimi-code.ts b/src/api/providers/kimi-code.ts index 0a50ce6ec3..3f7136806b 100644 --- a/src/api/providers/kimi-code.ts +++ b/src/api/providers/kimi-code.ts @@ -4,6 +4,8 @@ import { KIMI_CODE_BASE_URL, kimiCodeDefaultModelId, kimiCodeDefaultModelInfo, + providerIdentifiers, + type KimiCodeAuthMethod, type ModelInfo, type ModelRecord, } from "@roo-code/types" @@ -16,8 +18,12 @@ import type { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { OpenAiHandler } from "./openai" +import { NOT_PROVIDED } from "./constants" import { getModels } from "./fetchers/modelCache" +const OAUTH_AUTH_METHOD: KimiCodeAuthMethod = "oauth" +const API_KEY_AUTH_METHOD: KimiCodeAuthMethod = "api-key" + function getHttpStatus(error: unknown): number | undefined { if (!error || typeof error !== "object") return undefined const candidate = error as { status?: unknown; cause?: { status?: unknown } } @@ -37,7 +43,7 @@ export class KimiCodeHandler extends OpenAiHandler { super({ ...options, openAiBaseUrl: KIMI_CODE_BASE_URL, - openAiApiKey: options.kimiCodeApiKey ?? "not-provided", + openAiApiKey: options.kimiCodeApiKey ?? NOT_PROVIDED, openAiModelId: options.apiModelId ?? kimiCodeDefaultModelId, openAiStreamingEnabled: true, }) @@ -45,7 +51,7 @@ export class KimiCodeHandler extends OpenAiHandler { } private async resolveAccessToken(forceRefresh = false): Promise { - if ((this.kimiOptions.kimiCodeAuthMethod ?? "oauth") === "api-key") { + if ((this.kimiOptions.kimiCodeAuthMethod ?? OAUTH_AUTH_METHOD) === API_KEY_AUTH_METHOD) { if (!this.kimiOptions.kimiCodeApiKey) throw new Error("Kimi Code API key is required") return this.kimiOptions.kimiCodeApiKey } @@ -63,7 +69,7 @@ export class KimiCodeHandler extends OpenAiHandler { if (!this.modelDiscoveryAttempted) { this.modelDiscoveryAttempted = true try { - this.models = await getModels({ provider: "kimi-code", apiKey: accessToken }) + this.models = await getModels({ provider: providerIdentifiers.kimiCode, apiKey: accessToken }) } catch (error) { // Model discovery is best-effort; preserve the configured ID and fallback metadata. console.debug("[KimiCode] Model discovery failed; using fallback model metadata", { @@ -74,7 +80,7 @@ export class KimiCodeHandler extends OpenAiHandler { } private canRefreshOAuth(): boolean { - return (this.kimiOptions.kimiCodeAuthMethod ?? "oauth") === "oauth" + return (this.kimiOptions.kimiCodeAuthMethod ?? OAUTH_AUTH_METHOD) === OAUTH_AUTH_METHOD } override async *createMessage( diff --git a/src/api/providers/lite-llm.ts b/src/api/providers/lite-llm.ts index 74a610b073..8cfe2d0a19 100644 --- a/src/api/providers/lite-llm.ts +++ b/src/api/providers/lite-llm.ts @@ -1,7 +1,7 @@ import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" // Keep for type usage only -import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types" +import { litellmDefaultModelId, litellmDefaultModelInfo, providerIdentifiers } from "@roo-code/types" import { calculateApiCostOpenAI } from "../../shared/cost" @@ -27,7 +27,7 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa constructor(options: ApiHandlerOptions) { super({ options, - name: "litellm", + name: providerIdentifiers.litellm, baseURL: `${options.litellmBaseUrl || "http://localhost:4000"}`, apiKey: options.litellmApiKey || "dummy-key", modelId: options.litellmModelId, diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 79f5355ef8..0c828984bc 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -2,7 +2,12 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import axios from "axios" -import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATURE } from "@roo-code/types" +import { + type ModelInfo, + openAiModelInfoSaneDefaults, + LMSTUDIO_DEFAULT_TEMPERATURE, + providerIdentifiers, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" @@ -16,6 +21,7 @@ import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { getModelsFromCache } from "./fetchers/modelCache" import { handleOpenAIError } from "./utils/error-handler" +import { extractReasoningFromDelta } from "./utils/extract-reasoning" export class LmStudioHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions @@ -80,6 +86,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan } let assistantText = "" + let reasoningOutput = "" try { const params: OpenAI.Chat.ChatCompletionCreateParamsStreaming & { draft_model?: string } = { @@ -123,6 +130,15 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan } } + // Reasoning models served by LM Studio (Qwen3, DeepSeek-R1, QwQ, ...) stream + // their thinking in a dedicated `reasoning_content`/`reasoning` delta field + // rather than as tags inside `content`, so TagMatcher never sees it. + const reasoningText = extractReasoningFromDelta(delta) + if (reasoningText) { + reasoningOutput += reasoningText + yield { type: "reasoning", text: reasoningText } + } + // Handle tool calls in stream - emit partial chunks for NativeToolCallParser if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { @@ -151,7 +167,9 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan let outputTokens = 0 try { - outputTokens = await this.countTokens([{ type: "text", text: assistantText }]) + // Reasoning tokens are billed as output, so count them alongside the + // visible text — otherwise thinking models under-report usage entirely. + outputTokens = await this.countTokens([{ type: "text", text: reasoningOutput + assistantText }]) } catch (err) { console.error("[LmStudio] Failed to count output tokens:", err) outputTokens = 0 @@ -171,7 +189,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan override getModel(): { id: string; info: ModelInfo } { const models = getModelsFromCache({ - provider: "lmstudio", + provider: providerIdentifiers.lmstudio, baseUrl: this.options.lmStudioBaseUrl, }) if (models && this.options.lmStudioModelId && models[this.options.lmStudioModelId]) { diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index 2901c2e926..e3a794afae 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -12,6 +12,7 @@ import { handleProviderError } from "./utils/error-handler" import { extractReasoningFromDelta } from "./utils/extract-reasoning" import { OpenAiHandler } from "./openai" +import { NOT_PROVIDED } from "./constants" import type { ApiHandlerCreateMessageMetadata } from "../index" import { sanitizeOpenAiCallId } from "../../utils/tool-id" @@ -27,7 +28,7 @@ export class MimoHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { super({ ...options, - openAiApiKey: options.mimoApiKey ?? "not-provided", + openAiApiKey: options.mimoApiKey ?? NOT_PROVIDED, openAiModelId: options.apiModelId ?? mimoDefaultModelId, openAiBaseUrl: options.mimoBaseUrl || "https://token-plan-sgp.xiaomimimo.com/v1", openAiStreamingEnabled: true, diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts index 42bd2bfaf7..6fd012ef7f 100644 --- a/src/api/providers/moonshot.ts +++ b/src/api/providers/moonshot.ts @@ -8,6 +8,7 @@ import type { ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { OpenAiHandler } from "./openai" +import { NOT_PROVIDED } from "./constants" export class MoonshotHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { @@ -16,7 +17,7 @@ export class MoonshotHandler extends OpenAiHandler { // OpenAI Node SDK path as the generic "OpenAI Compatible" provider. super({ ...options, - openAiApiKey: options.moonshotApiKey ?? "not-provided", + openAiApiKey: options.moonshotApiKey ?? NOT_PROVIDED, openAiModelId: options.apiModelId ?? moonshotDefaultModelId, openAiBaseUrl: options.moonshotBaseUrl || "https://api.moonshot.ai/v1", }) diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts new file mode 100644 index 0000000000..38fb54c384 --- /dev/null +++ b/src/api/providers/nanogpt.ts @@ -0,0 +1,176 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { + applyNanoGptRoutingPreference, + NANOGPT_BASE_URL, + nanoGptDefaultModelId, + nanoGptDefaultModelInfo, + providerIdentifiers, + type NanoGptRoutingPreference, +} from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" + +import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { convertToOpenAiMessages } from "../transform/openai-format" +import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions, SingleCompletionHandler } from "../index" +import { RouterProvider } from "./router-provider" +import { handleProviderError } from "./utils/error-handler" +import { extractReasoningFromDelta } from "./utils/extract-reasoning" + +type NanoGptUsage = OpenAI.CompletionUsage & { + cache_read_input_tokens?: number + cache_creation_input_tokens?: number + reasoning_tokens?: number +} + +type NanoGptCachingRequest = { caching?: true } + +const OPENAI_REASONING_EFFORTS = ["low", "medium", "high"] as const +type OpenAiReasoningEffort = (typeof OPENAI_REASONING_EFFORTS)[number] + +function getReasoningEffort(options: ApiHandlerOptions, supported: unknown): OpenAiReasoningEffort | undefined { + const effort = options.reasoningEffort + const selectedEffort = OPENAI_REASONING_EFFORTS.find((candidate) => candidate === effort) + if (!selectedEffort) { + return undefined + } + + if (supported === true || (Array.isArray(supported) && supported.includes(selectedEffort))) { + return selectedEffort + } + + return undefined +} + +function mapNanoGptUsage(usage: NanoGptUsage): ApiStreamUsageChunk { + return { + type: "usage", + inputTokens: usage.prompt_tokens ?? 0, + outputTokens: usage.completion_tokens ?? 0, + cacheReadTokens: usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens, + cacheWriteTokens: usage.cache_creation_input_tokens, + reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? usage.reasoning_tokens, + } +} + +export class NanoGptHandler extends RouterProvider implements SingleCompletionHandler { + constructor(options: ApiHandlerOptions) { + super({ + options, + name: providerIdentifiers.nanogpt, + baseURL: NANOGPT_BASE_URL, + apiKey: options.nanoGptApiKey, + modelId: options.nanoGptModelId, + defaultModelId: nanoGptDefaultModelId, + defaultModelInfo: nanoGptDefaultModelInfo, + }) + } + + private getRequestModelId(canonicalModelId: string): string { + return applyNanoGptRoutingPreference( + canonicalModelId, + this.options.nanoGptRoutingPreference as NanoGptRoutingPreference | undefined, + ) + } + + private createSafeError(operation: string, error: unknown): Error { + return handleProviderError(error, "NanoGPT", { + messagePrefix: operation, + messageTransformer: (message) => + this.options.nanoGptApiKey + ? `NanoGPT ${operation} error: ${message.replaceAll(this.options.nanoGptApiKey, "[REDACTED]")}` + : `NanoGPT ${operation} error: ${message}`, + }) + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { id: canonicalModelId, info } = await this.fetchModel() + const body: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & NanoGptCachingRequest = { + model: this.getRequestModelId(canonicalModelId), + messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + stream: true, + stream_options: { include_usage: true }, + max_tokens: info.maxTokens ?? undefined, + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + parallel_tool_calls: metadata?.parallelToolCalls ?? true, + ...(this.options.nanoGptRoutingPreference === "caching" ? { caching: true } : {}), + } + + if (this.options.modelTemperature !== undefined && this.supportsTemperature(canonicalModelId)) { + body.temperature = this.options.modelTemperature + } + + const reasoningEffort = getReasoningEffort(this.options, info.supportsReasoningEffort) + if (reasoningEffort) { + body.reasoning_effort = reasoningEffort + } + + try { + const completion = await this.client.chat.completions.create(body, { signal: metadata?.abortSignal }) + for await (const chunk of completion) { + const delta = chunk.choices[0]?.delta + if (delta?.content) { + yield { type: "text", text: delta.content } + } + + const reasoning = extractReasoningFromDelta(delta) + if (reasoning) { + yield { type: "reasoning", text: reasoning } + } + + for (const toolCall of delta?.tool_calls ?? []) { + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + + if (chunk.usage) { + yield mapNanoGptUsage(chunk.usage as NanoGptUsage) + } + } + } catch (error) { + throw this.createSafeError("streaming", error) + } + } + + async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + const { id: canonicalModelId, info } = await this.fetchModel() + const body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming & NanoGptCachingRequest = { + model: this.getRequestModelId(canonicalModelId), + messages: [{ role: "user", content: prompt }], + stream: false, + max_tokens: info.maxTokens ?? undefined, + ...(this.options.nanoGptRoutingPreference === "caching" ? { caching: true } : {}), + } + + if (this.options.modelTemperature !== undefined && this.supportsTemperature(canonicalModelId)) { + body.temperature = this.options.modelTemperature + } + + const reasoningEffort = getReasoningEffort(this.options, info.supportsReasoningEffort) + if (reasoningEffort) { + body.reasoning_effort = reasoningEffort + } + + try { + const response = await this.client.chat.completions.create(body, { + signal: options?.abortSignal, + timeout: options?.timeoutMs, + }) + return response.choices[0]?.message.content ?? "" + } catch (error) { + throw this.createSafeError("completion", error) + } + } +} diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 8dffe03dcc..a919e2c82b 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -28,6 +28,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" +import { NOT_PROVIDED } from "./constants" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { isMcpTool } from "../../utils/mcp-name" import { sanitizeOpenAiCallId } from "../../utils/tool-id" @@ -95,7 +96,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio if (this.options.enableResponsesReasoningSummary === undefined) { this.options.enableResponsesReasoningSummary = true } - const apiKey = this.options.openAiNativeApiKey ?? "not-provided" + const apiKey = this.options.openAiNativeApiKey ?? NOT_PROVIDED // Include originator, session_id, and User-Agent headers for API tracking and debugging const userAgent = `zoo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` this.client = new OpenAI({ @@ -555,7 +556,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio systemPrompt?: string, messages?: Anthropic.Messages.MessageParam[], ): ApiStream { - const apiKey = this.options.openAiNativeApiKey ?? "not-provided" + const apiKey = this.options.openAiNativeApiKey ?? NOT_PROVIDED const baseUrl = this.options.openAiNativeBaseUrl || "https://api.openai.com" const url = `${baseUrl}/v1/responses` diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 5b4476fdef..5588dd37d6 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -21,7 +21,7 @@ import { convertToR1Format } from "../transform/r1-format" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { handleOpenAIError } from "./utils/error-handler" @@ -40,7 +40,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl this.options = options const baseURL = this.options.openAiBaseUrl || "https://api.openai.com/v1" - const apiKey = this.options.openAiApiKey ?? "not-provided" + const apiKey = this.options.openAiApiKey ?? NOT_PROVIDED const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) const isAzureOpenAi = isAzureOpenAiBaseUrl(this.options.openAiBaseUrl, options.openAiUseAzure) diff --git a/src/api/providers/opencode-go.ts b/src/api/providers/opencode-go.ts index be53dc1c02..9456ac8fdb 100644 --- a/src/api/providers/opencode-go.ts +++ b/src/api/providers/opencode-go.ts @@ -8,6 +8,7 @@ import { opencodeGoDefaultModelInfo, OPENCODE_GO_DEFAULT_TEMPERATURE, isOpencodeGoAnthropicFormatModel, + providerIdentifiers, } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" @@ -54,9 +55,9 @@ import { * * - OpenAI-compatible chat completions (`/v1/chat/completions`, "oa-compat") * — used by GLM, Kimi, DeepSeek, and MiMo models. - * - Anthropic Messages (`/v1/messages`) — used by Qwen (qwen3.7-max, - * qwen3.7-plus, qwen3.6-plus) and MiniMax (minimax-m3, minimax-m2.7, - * minimax-m2.5) models. + * - Anthropic Messages (`/v1/messages`) — used by Qwen (qwen3.8-max, + * qwen3.7-max, qwen3.7-plus, qwen3.6-plus) and MiniMax (minimax-m3, + * minimax-m2.7, minimax-m2.5) models. * * Sending an Anthropic-format model to the chat completions endpoint is * rejected with `401 Model is not supported for format oa-compat`, so this @@ -81,7 +82,7 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio constructor(options: ApiHandlerOptions) { super({ options, - name: "opencode-go", + name: providerIdentifiers.opencodeGo, baseURL: "https://opencode.ai/zen/go/v1", apiKey: options.opencodeGoApiKey, modelId: options.opencodeGoModelId, diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 3e59b4360b..f61e007214 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -10,6 +10,7 @@ import { OPENROUTER_DEFAULT_PROVIDER_NAME, OPEN_ROUTER_PROMPT_CACHING_MODELS, DEEP_SEEK_DEFAULT_TEMPERATURE, + providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -33,7 +34,7 @@ import { getModelParams } from "../transform/model-params" import { getModels } from "./fetchers/modelCache" import { getModelEndpoints } from "./fetchers/modelEndpointCache" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { BaseProvider } from "./base-provider" import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions, SingleCompletionHandler } from "../index" import { handleOpenAIError } from "./utils/error-handler" @@ -151,7 +152,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH this.options = options const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1" - const apiKey = this.options.openRouterApiKey ?? "not-provided" + const apiKey = this.options.openRouterApiKey ?? NOT_PROVIDED this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS, timeout: this.timeoutMs }) @@ -164,9 +165,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH private async loadDynamicModels(): Promise { try { const [models, endpoints] = await Promise.all([ - getModels({ provider: "openrouter" }), + getModels({ provider: providerIdentifiers.openrouter }), getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: this.options.openRouterModelId, endpoint: this.options.openRouterSpecificProvider, }), @@ -197,7 +198,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const rawErrorMessage = parsedError || error?.message || "Unknown error" const apiError = Object.assign( - new ApiProviderError(rawErrorMessage, this.providerName, modelId, operation, error?.code), + new ApiProviderError(rawErrorMessage, providerIdentifiers.openrouter, modelId, operation, error?.code), { status: error?.code, error }, ) @@ -352,7 +353,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const apiError = Object.assign( new ApiProviderError( rawErrorMessage, - this.providerName, + providerIdentifiers.openrouter, modelId, "createMessage", openRouterError.error?.code, @@ -368,7 +369,12 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } else { // Fallback for non-OpenRouter errors const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") + const apiError = new ApiProviderError( + errorMessage, + providerIdentifiers.openrouter, + modelId, + "createMessage", + ) TelemetryService.instance.captureException(apiError) throw handleOpenAIError(error, this.providerName) } @@ -535,9 +541,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH public async fetchModel() { const [models, endpoints] = await Promise.all([ - getModels({ provider: "openrouter" }), + getModels({ provider: providerIdentifiers.openrouter }), getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: this.options.openRouterModelId, endpoint: this.options.openRouterSpecificProvider, }), @@ -617,7 +623,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const apiError = Object.assign( new ApiProviderError( rawErrorMessage, - this.providerName, + providerIdentifiers.openrouter, modelId, "completePrompt", openRouterError.error?.code, @@ -633,7 +639,12 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } else { // Fallback for non-OpenRouter errors const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") + const apiError = new ApiProviderError( + errorMessage, + providerIdentifiers.openrouter, + modelId, + "completePrompt", + ) TelemetryService.instance.captureException(apiError) throw handleOpenAIError(error, this.providerName) } diff --git a/src/api/providers/poe.ts b/src/api/providers/poe.ts index 1e5315b1ba..fb3255c572 100644 --- a/src/api/providers/poe.ts +++ b/src/api/providers/poe.ts @@ -9,6 +9,7 @@ import { type ModelInfo, type ReasoningEffortExtended, ApiProviderError, + providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -18,6 +19,7 @@ import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" +import { NOT_PROVIDED } from "./constants" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { getModelsFromCache } from "./fetchers/modelCache" @@ -31,7 +33,7 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler super() this.options = options this.poe = createPoe({ - apiKey: options.poeApiKey ?? "not-provided", + apiKey: options.poeApiKey ?? NOT_PROVIDED, baseURL: options.poeBaseUrl || undefined, }) } @@ -39,7 +41,7 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler override getModel() { const id = this.options.apiModelId ?? poeDefaultModelId const cached = getModelsFromCache({ - provider: "poe", + provider: providerIdentifiers.poe, apiKey: this.options.poeApiKey, baseUrl: this.options.poeBaseUrl, }) @@ -108,7 +110,9 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler }) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - TelemetryService.instance.captureException(new ApiProviderError(errorMessage, "poe", id, "createMessage")) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, providerIdentifiers.poe, id, "createMessage"), + ) throw new Error(`Poe completion error: ${errorMessage}`) } @@ -133,7 +137,9 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - TelemetryService.instance.captureException(new ApiProviderError(errorMessage, "poe", id, "createMessage")) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, providerIdentifiers.poe, id, "createMessage"), + ) throw new Error(`Poe streaming error: ${errorMessage}`) } } @@ -148,7 +154,9 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler return text } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - TelemetryService.instance.captureException(new ApiProviderError(errorMessage, "poe", id, "completePrompt")) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, providerIdentifiers.poe, id, "completePrompt"), + ) throw new Error(`Poe completion error: ${errorMessage}`) } } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 5753660de5..1ba0771ce2 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -1,7 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { type ModelInfo, type ModelRecord, requestyDefaultModelId, requestyDefaultModelInfo } from "@roo-code/types" +import { + type ModelInfo, + type ModelRecord, + providerIdentifiers, + requestyDefaultModelId, + requestyDefaultModelInfo, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" @@ -11,7 +17,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { AnthropicProviderReasoningParams, getAnthropicProviderReasoning } from "../transform/reasoning" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { getModels } from "./fetchers/modelCache" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" @@ -63,7 +69,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan this.options = options this.baseURL = toRequestyServiceUrl(options.requestyBaseUrl) - const apiKey = this.options.requestyApiKey ?? "not-provided" + const apiKey = this.options.requestyApiKey ?? NOT_PROVIDED this.client = new OpenAI({ baseURL: this.baseURL, @@ -74,7 +80,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan } public async fetchModel() { - this.models = await getModels({ provider: "requesty", baseUrl: this.baseURL }) + this.models = await getModels({ provider: providerIdentifiers.requesty, baseUrl: this.baseURL }) return this.getModel() } diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index cbdd49e58b..7292824da8 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -5,9 +5,9 @@ import { type ModelInfo, type ModelRecord } from "@roo-code/types" import { ApiHandlerOptions, RouterName } from "../../shared/api" import { BaseProvider } from "./base-provider" -import { getModels, getModelsFromCache } from "./fetchers/modelCache" +import { getModels, getModelsFromCache, refreshModels } from "./fetchers/modelCache" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" type RouterProviderOptions = { name: RouterName @@ -26,17 +26,10 @@ export abstract class RouterProvider extends BaseProvider { protected readonly modelId?: string protected readonly defaultModelId: string protected readonly defaultModelInfo: ModelInfo + protected readonly apiKey?: string protected readonly client: OpenAI - constructor({ - options, - name, - baseURL, - apiKey = "not-provided", - modelId, - defaultModelId, - defaultModelInfo, - }: RouterProviderOptions) { + constructor({ options, name, baseURL, apiKey, modelId, defaultModelId, defaultModelInfo }: RouterProviderOptions) { super() this.options = options @@ -44,10 +37,11 @@ export abstract class RouterProvider extends BaseProvider { this.modelId = modelId this.defaultModelId = defaultModelId this.defaultModelInfo = defaultModelInfo + this.apiKey = apiKey this.client = new OpenAI({ baseURL, - apiKey, + apiKey: apiKey ?? NOT_PROVIDED, defaultHeaders: { ...DEFAULT_HEADERS, ...(options.openAiHeaders || {}), @@ -57,25 +51,58 @@ export abstract class RouterProvider extends BaseProvider { } private modelFetchPromise?: Promise<{ id: string; info: ModelInfo }> + /** Last catalog refresh attempt per missing model id (ms), for negative caching. */ + private missingModelRefreshAt = new Map() + private static readonly MISSING_MODEL_RETRY_MS = 5 * 60 * 1000 public async fetchModel() { - if (Object.keys(this.models).length > 0) { + // Refetch when the selected model is missing — a stale non-empty map + // would otherwise keep serving defaultModelInfo prices for cost estimates. + const id = this.modelId || this.defaultModelId + if (this.models[id]) { + return this.getModel() + } + + // After a catalog fetch that still lacks this id, don't hammer getModels + // on every createMessage; retry only after the negative-cache window. + const lastMissingAttempt = this.missingModelRefreshAt.get(id) + if ( + lastMissingAttempt !== undefined && + Date.now() - lastMissingAttempt < RouterProvider.MISSING_MODEL_RETRY_MS + ) { return this.getModel() } if (!this.modelFetchPromise) { - this.modelFetchPromise = getModels({ + const fetchOptions = { provider: this.name, - apiKey: this.client.apiKey, + apiKey: this.apiKey, baseUrl: this.client.baseURL, - }) - .then((models) => { + } + + this.modelFetchPromise = (async () => { + let models = await getModels(fetchOptions) + this.models = models + + // getModels may return a shared cached catalog that predates this + // model. Force a provider refresh before recording a miss so + // newly listed models are not blocked for MISSING_MODEL_RETRY_MS. + // Auth-scoped providers already bypass that cache in getModels; + // refreshModels is then a no-op extra live fetch only on true misses. + if (!models[id]) { + models = await refreshModels(fetchOptions) this.models = models - return this.getModel() - }) - .finally(() => { - this.modelFetchPromise = undefined - }) + } + + if (models[id]) { + this.missingModelRefreshAt.delete(id) + } else { + this.missingModelRefreshAt.set(id, Date.now()) + } + return this.getModel() + })().finally(() => { + this.modelFetchPromise = undefined + }) } return this.modelFetchPromise @@ -105,7 +132,7 @@ export abstract class RouterProvider extends BaseProvider { const cachedModels = getModelsFromCache({ provider: this.name, baseUrl: this.client.baseURL, - apiKey: this.client.apiKey, + apiKey: this.apiKey, }) if (cachedModels?.[id]) { // Also populate instance models for future calls @@ -113,10 +140,21 @@ export abstract class RouterProvider extends BaseProvider { return { id, info: cachedModels[id] } } - // Last resort: preserve the configured model ID (falling back to the default - // only when none is configured) so an as-yet-unfetched model isn't silently - // swapped for the hardcoded default. info still comes from defaults since we - // have no fetched or cached metadata for the configured model at this point. + // Last resort: keep the configured id so we don't swap models, but zero + // prices so we don't bill the UI with defaultModelInfo's $/token rates. + if (id !== this.defaultModelId) { + return { + id, + info: { + ...this.defaultModelInfo, + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + }, + } + } + return { id, info: this.defaultModelInfo } } diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index c3ec9c44fc..0848e0804b 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -1,7 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { type ModelInfo, type ModelRecord, unboundDefaultModelId, unboundDefaultModelInfo } from "@roo-code/types" +import { + type ModelInfo, + type ModelRecord, + providerIdentifiers, + unboundDefaultModelId, + unboundDefaultModelInfo, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" @@ -11,7 +17,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { OpenAiReasoningParams } from "../transform/reasoning" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { getModels } from "./fetchers/modelCache" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" @@ -54,7 +60,7 @@ export class UnboundHandler extends BaseProvider implements SingleCompletionHand this.options = options - const apiKey = this.options.unboundApiKey ?? "not-provided" + const apiKey = this.options.unboundApiKey ?? NOT_PROVIDED this.client = new OpenAI({ baseURL: "https://api.getunbound.ai/v1", @@ -68,7 +74,10 @@ export class UnboundHandler extends BaseProvider implements SingleCompletionHand } public async fetchModel() { - this.models = await getModels({ provider: "unbound", apiKey: this.options.unboundApiKey }) + this.models = await getModels({ + provider: providerIdentifiers.unbound, + apiKey: this.options.unboundApiKey, + }) return this.getModel() } diff --git a/src/api/providers/utils/__tests__/abort-signal.spec.ts b/src/api/providers/utils/__tests__/abort-signal.spec.ts new file mode 100644 index 0000000000..ebc7edf3d3 --- /dev/null +++ b/src/api/providers/utils/__tests__/abort-signal.spec.ts @@ -0,0 +1,102 @@ +import { mergeAbortSignalAndTimeout, mergeAbortSignals } from "../abort-signal" + +describe("abort-signal utilities", () => { + describe("mergeAbortSignalAndTimeout", () => { + it("returns undefined when no signal or positive timeout is provided", () => { + expect(mergeAbortSignalAndTimeout(undefined, 0)).toBeUndefined() + expect(mergeAbortSignalAndTimeout(undefined, -1)).toBeUndefined() + expect(mergeAbortSignalAndTimeout(undefined, NaN)).toBeUndefined() + expect(mergeAbortSignalAndTimeout()).toBeUndefined() + }) + + it("forwards external signal directly when timeout is disabled", () => { + const controller = new AbortController() + + expect(mergeAbortSignalAndTimeout(controller.signal, -1)).toBe(controller.signal) + expect(mergeAbortSignalAndTimeout(controller.signal, NaN)).toBe(controller.signal) + expect(mergeAbortSignalAndTimeout(controller.signal)).toBe(controller.signal) + }) + + it("creates a self-managed timeout signal when only positive timeout is provided", async () => { + const result = mergeAbortSignalAndTimeout(undefined, 50) + + expect(result).toBeInstanceOf(AbortSignal) + expect(result?.aborted).toBe(false) + + await vi.waitFor(() => expect(result?.aborted).toBe(true)) + }) + + it("merges external signal and timeout signal", () => { + const controller = new AbortController() + + const result = mergeAbortSignalAndTimeout(controller.signal, 100) + + expect(result).toBeInstanceOf(AbortSignal) + expect(result).not.toBe(controller.signal) + expect(result?.aborted).toBe(false) + + controller.abort() + + expect(result?.aborted).toBe(true) + }) + + it("aborts via timeout alone when the external signal stays active", async () => { + const controller = new AbortController() + + const result = mergeAbortSignalAndTimeout(controller.signal, 50) + + expect(result).not.toBe(controller.signal) + expect(result?.aborted).toBe(false) + + await vi.waitFor(() => expect(result?.aborted).toBe(true)) + }) + }) + + describe("mergeAbortSignals", () => { + it("returns primary signal directly when secondary signal is absent", () => { + const controller = new AbortController() + + const result = mergeAbortSignals(controller.signal) + + expect(result).toBe(controller.signal) + }) + + it("returns a merged signal when secondary signal is present", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + + const result = mergeAbortSignals(primaryController.signal, secondaryController.signal) + + expect(result).not.toBe(primaryController.signal) + expect(result).not.toBe(secondaryController.signal) + expect(result.aborted).toBe(false) + + secondaryController.abort() + + expect(result.aborted).toBe(true) + }) + + it("aborts merged signal when primary signal is aborted", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + + const result = mergeAbortSignals(primaryController.signal, secondaryController.signal) + + expect(result.aborted).toBe(false) + + primaryController.abort() + + expect(result.aborted).toBe(true) + }) + + it("returns an aborted signal when primary is already aborted", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + primaryController.abort() + + const result = mergeAbortSignals(primaryController.signal, secondaryController.signal) + + expect(result.aborted).toBe(true) + }) + }) +}) diff --git a/src/api/providers/utils/abort-signal.ts b/src/api/providers/utils/abort-signal.ts new file mode 100644 index 0000000000..73e0356f7b --- /dev/null +++ b/src/api/providers/utils/abort-signal.ts @@ -0,0 +1,37 @@ +/** + * Merge an optional external abort signal with an optional timeout. + * + * Timeout values <= 0 are treated as disabled. The timeout is created via the + * native AbortSignal.timeout() API, which self-manages its timer — callers do + * not need to (and cannot) clear it manually. + */ +export function mergeAbortSignalAndTimeout(externalSignal?: AbortSignal, timeoutMs?: number): AbortSignal | undefined { + const hasTimeout = typeof timeoutMs === "number" && timeoutMs > 0 + + if (!hasTimeout) { + return externalSignal + } + + const timeoutSignal = AbortSignal.timeout(timeoutMs) + + if (!externalSignal) { + return timeoutSignal + } + + return mergeAbortSignals(externalSignal, timeoutSignal) +} + +/** + * Merge two abort signals using the standard AbortSignal.any() API. + * + * Returns the primary signal directly when no secondary signal is provided to + * avoid creating unnecessary controllers/listeners for the common single-signal + * path. + */ +export function mergeAbortSignals(primarySignal: AbortSignal, secondarySignal?: AbortSignal): AbortSignal { + if (!secondarySignal) { + return primarySignal + } + + return AbortSignal.any([primarySignal, secondarySignal]) +} diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index 0820e1b2d8..bf434e5a00 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -6,6 +6,7 @@ import { vercelAiGatewayDefaultModelInfo, VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE, VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS, + providerIdentifiers, } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" @@ -27,7 +28,7 @@ export class VercelAiGatewayHandler extends RouterProvider implements SingleComp constructor(options: ApiHandlerOptions) { super({ options, - name: "vercel-ai-gateway", + name: providerIdentifiers.vercelAiGateway, baseURL: "https://ai-gateway.vercel.sh/v1", apiKey: options.vercelAiGatewayApiKey, modelId: options.vercelAiGatewayModelId, diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index c47e9cefd1..189ec4e9ed 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -11,7 +11,7 @@ import { convertToResponsesApiInput } from "../transform/responses-api-input" import { processResponsesApiStream, createUsageNormalizer } from "../transform/responses-api-stream" import { getModelParams } from "../transform/model-params" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { handleOpenAIError } from "./utils/error-handler" @@ -28,7 +28,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler super() this.options = options - const apiKey = this.options.xaiApiKey ?? "not-provided" + const apiKey = this.options.xaiApiKey ?? NOT_PROVIDED this.client = new OpenAI({ baseURL: "https://api.x.ai/v1", diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index 4854c814fd..c53a434e38 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -2,42 +2,43 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { - internationalZAiModels, - mainlandZAiModels, internationalZAiDefaultModelId, mainlandZAiDefaultModelId, type ModelInfo, ZAI_DEFAULT_TEMPERATURE, zaiApiLineConfigs, + getZAiModels, } from "@roo-code/types" import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/api" import { convertToZAiFormat } from "../transform/zai-format" -import type { ApiHandlerCreateMessageMetadata } from "../index" +import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { NOT_PROVIDED } from "./constants" import { handleOpenAIError } from "./utils/error-handler" // Custom interface for Z.ai params to support thinking mode and reasoning effort tiers. // Z.ai accepts the standard `reasoning_effort` ladder (none/minimal/low/medium/high/xhigh/max) // alongside the GLM-specific `thinking` toggle. Omit the OpenAI-typed `reasoning_effort` so we // can widen it to include provider-specific values such as "max". -type ZAiChatCompletionParams = Omit & { - thinking?: { type: "enabled" | "disabled" } +type ZAiChatCompletionParams = Omit & { + thinking?: { type: "enabled" | "disabled"; clear_thinking?: boolean } reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" } export class ZAiHandler extends BaseOpenAiCompatibleProvider { constructor(options: ApiHandlerOptions) { - const isChina = zaiApiLineConfigs[options.zaiApiLine ?? "international_coding"].isChina - const models = (isChina ? mainlandZAiModels : internationalZAiModels) as unknown as Record + const apiLine = options.zaiApiLine ?? "international_coding" + const isChina = zaiApiLineConfigs[apiLine].isChina + const models = getZAiModels(apiLine) const defaultModelId = (isChina ? mainlandZAiDefaultModelId : internationalZAiDefaultModelId) as string super({ ...options, providerName: "Z.ai", - baseURL: zaiApiLineConfigs[options.zaiApiLine ?? "international_coding"].baseUrl, - apiKey: options.zaiApiKey ?? "not-provided", + baseURL: zaiApiLineConfigs[apiLine].baseUrl, + apiKey: options.zaiApiKey ?? NOT_PROVIDED, defaultProviderModelId: defaultModelId, providerModels: models, defaultTemperature: ZAI_DEFAULT_TEMPERATURE, @@ -78,19 +79,7 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { metadata?: ApiHandlerCreateMessageMetadata, ) { const { id: model, info } = this.getModel() - - // Fall back to the model default when the resolved effort isn't supported by the model. - const supported = info.supportsReasoningEffort - const raw = - this.options.enableReasoningEffort === false - ? undefined - : (this.options.reasoningEffort ?? info.reasoningEffort) - const effort = - raw && raw !== "disable" && Array.isArray(supported) && !supported.includes(raw) - ? info.reasoningEffort - : raw - const reasoningEffort = effort && effort !== "disable" ? effort : undefined - const useReasoning = reasoningEffort !== undefined + const { reasoningEffort, useReasoning } = this.getReasoningSettings(info) const max_tokens = this.options.modelMaxTokens || @@ -102,7 +91,7 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { }) ?? undefined) - const temperature = this.options.modelTemperature ?? this.defaultTemperature + const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature // Use Z.ai format to preserve reasoning_content and merge post-tool text into tool messages const convertedMessages = convertToZAiFormat(messages, { mergeToolResultText: true }) @@ -114,8 +103,10 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { messages: [{ role: "system", content: systemPrompt }, ...convertedMessages], stream: true, stream_options: { include_usage: true }, - // Thinking is ON by default for these models, so explicitly disable it when needed. - thinking: useReasoning ? { type: "enabled" } : { type: "disabled" }, + // Models with required reasoning stay enabled even when an old setting requests disable. + thinking: useReasoning + ? { type: "enabled", ...(model === "glm-5.3" && { clear_thinking: false }) } + : { type: "disabled" }, reasoning_effort: reasoningEffort, tools: this.convertToolsForOpenAI(metadata?.tools), tool_choice: metadata?.tool_choice, @@ -130,4 +121,48 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { throw handleOpenAIError(error, this.providerName) } } + + private getReasoningSettings(info: ModelInfo) { + // Fall back to the model default when the resolved effort isn't supported by the model. + const supported = info.supportsReasoningEffort + const raw = + this.options.enableReasoningEffort === false + ? undefined + : (this.options.reasoningEffort ?? info.reasoningEffort) + const requiresReasoning = info.requiredReasoningEffort === true + const effort = + requiresReasoning && (!raw || raw === "disable") + ? info.reasoningEffort + : raw && Array.isArray(supported) && !supported.includes(raw) + ? info.reasoningEffort + : raw + const reasoningEffort = effort && effort !== "disable" ? effort : undefined + + return { reasoningEffort, useReasoning: requiresReasoning || reasoningEffort !== undefined } + } + + override async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + const { id: model, info } = this.getModel() + if (model !== "glm-5.3") { + return super.completePrompt(prompt, options) + } + + const { reasoningEffort } = this.getReasoningSettings(info) + const params: ZAiChatCompletionParams = { + model, + messages: [{ role: "user", content: prompt }], + temperature: this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature, + thinking: { type: "enabled", clear_thinking: false }, + reasoning_effort: reasoningEffort, + } + + try { + const response = await this.client.chat.completions.create( + params as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming, + ) + return response.choices?.[0]?.message.content || "" + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } + } } diff --git a/src/api/providers/zoo-gateway.ts b/src/api/providers/zoo-gateway.ts index cea410bb2e..4ff059df61 100644 --- a/src/api/providers/zoo-gateway.ts +++ b/src/api/providers/zoo-gateway.ts @@ -7,6 +7,7 @@ import { zooGatewayDefaultModelInfo, ZOO_GATEWAY_DEFAULT_TEMPERATURE, VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS, + providerIdentifiers, } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" @@ -19,6 +20,7 @@ import { convertToOpenAiMessages } from "../transform/openai-format" import { addCacheBreakpoints } from "../transform/caching/vercel-ai-gateway" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" +import { NOT_PROVIDED } from "./constants" import { RouterProvider } from "./router-provider" function getApiErrorStatus(error: unknown): number | undefined { @@ -159,9 +161,9 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio ...(options.openAiHeaders || {}), }, }, - name: "zoo-gateway", + name: providerIdentifiers.zooGateway, baseURL, - apiKey: sessionToken || "not-provided", + apiKey: sessionToken || NOT_PROVIDED, modelId: options.zooGatewayModelId, defaultModelId: zooGatewayDefaultModelId, defaultModelInfo: zooGatewayDefaultModelInfo, diff --git a/src/api/transform/__tests__/reasoning.spec.ts b/src/api/transform/__tests__/reasoning.spec.ts index a14fffe550..004df4e60c 100644 --- a/src/api/transform/__tests__/reasoning.spec.ts +++ b/src/api/transform/__tests__/reasoning.spec.ts @@ -1,6 +1,7 @@ // npx vitest run src/api/transform/__tests__/reasoning.spec.ts import type { ModelInfo, ProviderSettings, ReasoningEffortWithMinimal } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { getOpenRouterReasoning, @@ -703,7 +704,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, enableReasoningEffort: true, reasoningEffort: "high", } @@ -730,7 +731,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, // Even with this flag false, an explicit effort selection should win enableReasoningEffort: false, reasoningEffort: "high", @@ -755,7 +756,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, reasoningEffort: "minimal", } @@ -778,7 +779,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, reasoningEffort: "medium", } @@ -809,7 +810,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, reasoningEffort: level, } @@ -833,7 +834,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, reasoningEffort: "disable", } @@ -856,7 +857,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, reasoningEffort: "none", } @@ -880,7 +881,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, enableReasoningEffort: true, } @@ -904,7 +905,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, enableReasoningEffort: true, reasoningEffort: "high", } @@ -929,7 +930,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, } const options: GetModelReasoningOptions = { @@ -953,7 +954,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, reasoningEffort: "medium", } @@ -977,7 +978,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, reasoningEffort: "medium", } @@ -1001,7 +1002,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, reasoningEffort: "high", } @@ -1025,7 +1026,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, reasoningEffort: "medium", } @@ -1049,7 +1050,7 @@ describe("reasoning.ts", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, reasoningEffort: "minimal", } diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 551591b0ed..2319a6b1a5 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -8,6 +8,7 @@ import { clearAllMocks } from "../../../test-utils/reset" import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" import { ContextProxy } from "../ContextProxy" +import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" vi.mock("vscode", () => ({ Uri: { @@ -270,7 +271,7 @@ describe("ContextProxy", () => { // Test with multiple values await proxy.setValues({ apiModelId: "gpt-4", - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, mode: "test-mode", }) @@ -308,6 +309,25 @@ describe("ContextProxy", () => { }) describe("setProviderSettings", () => { + it("stores and returns the complete NanoGPT configuration across secret and global state", async () => { + await proxy.setProviderSettings({ + apiProvider: providerIdentifiers.nanogpt, + nanoGptApiKey: "nanogpt-secret", + nanoGptModelId: "openai/model", + nanoGptRoutingPreference: "throughput", + }) + + expect(mockSecrets.store).toHaveBeenCalledWith("nanoGptApiKey", "nanogpt-secret") + expect(mockGlobalState.update).toHaveBeenCalledWith("nanoGptModelId", "openai/model") + expect(mockGlobalState.update).toHaveBeenCalledWith("nanoGptRoutingPreference", "throughput") + expect(proxy.getProviderSettings()).toMatchObject({ + apiProvider: providerIdentifiers.nanogpt, + nanoGptApiKey: "nanogpt-secret", + nanoGptModelId: "openai/model", + nanoGptRoutingPreference: "throughput", + }) + }) + it("should clear old API configuration values and set new ones", async () => { // Set up initial API configuration values await proxy.updateGlobalState("apiModelId", "old-model") @@ -320,7 +340,7 @@ describe("ContextProxy", () => { // Call setProviderSettings with new configuration await proxy.setProviderSettings({ apiModelId: "new-model", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, // Note: openAiBaseUrl is not included in the new config }) @@ -330,7 +350,7 @@ describe("ContextProxy", () => { expect(setValuesSpy).toHaveBeenCalledWith( expect.objectContaining({ apiModelId: "new-model", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, openAiBaseUrl: undefined, modelTemperature: undefined, }), @@ -546,7 +566,7 @@ describe("ContextProxy", () => { it("should preserve retired apiProvider and provider fields", async () => { await proxy.setValues({ - apiProvider: "groq", + apiProvider: retiredProviderIdentifiers.groq, apiModelId: "llama3-70b", openAiBaseUrl: "https://api.retired-provider.example/v1", apiKey: "retired-provider-key", diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 56d4a6951b..b7a0a9595c 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -4,6 +4,7 @@ import { OPEN_AI_CODEX_SERVICE_TIER_KEY, OpenAiCodexServiceTier, providerIdentifiers, + retiredProviderIdentifiers, type ProviderSettings, } from "@roo-code/types" @@ -119,7 +120,7 @@ describe("ProviderSettingsManager", () => { config: {}, }, test: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, }, }, migrations: { @@ -151,11 +152,11 @@ describe("ProviderSettingsManager", () => { rateLimitSeconds: undefined, }, test: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, rateLimitSeconds: undefined, }, existing: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, // this should not really be possible, unless someone has loaded a hand edited config, // but we don't overwrite so we'll check that rateLimitSeconds: 43, @@ -188,11 +189,11 @@ describe("ProviderSettingsManager", () => { consecutiveMistakeLimit: undefined, }, test: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, consecutiveMistakeLimit: undefined, }, existing: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, // this should not really be possible, unless someone has loaded a hand edited config, // but we don't overwrite so we'll check that consecutiveMistakeLimit: 5, @@ -228,11 +229,11 @@ describe("ProviderSettingsManager", () => { todoListEnabled: undefined, }, test: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, todoListEnabled: undefined, }, existing: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, // this should not really be possible, unless someone has loaded a hand edited config, // but we don't overwrite so we'll check that todoListEnabled: false, @@ -266,19 +267,19 @@ describe("ProviderSettingsManager", () => { default: { config: {}, id: "default", - apiProvider: "roo", + apiProvider: retiredProviderIdentifiers.roo, apiModelId: "roo/code-supernova", // Old model ID }, test: { - apiProvider: "roo", + apiProvider: retiredProviderIdentifiers.roo, apiModelId: "roo/code-supernova", // Old model ID }, existing: { - apiProvider: "roo", + apiProvider: retiredProviderIdentifiers.roo, apiModelId: "roo/code-supernova-1-million", // Already migrated }, otherProvider: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "roo/code-supernova", // Should not be migrated (different provider) }, noProvider: { @@ -329,7 +330,7 @@ describe("ProviderSettingsManager", () => { await providerSettingsManager.saveConfig("router-profile", { id: "router-id", - apiProvider: "roo", + apiProvider: retiredProviderIdentifiers.roo, apiModelId: "roo/code-supernova", rooApiKey: "router-key", } as any) @@ -357,7 +358,7 @@ describe("ProviderSettingsManager", () => { id: "default", }, test: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, id: "test-id", }, }, @@ -376,7 +377,7 @@ describe("ProviderSettingsManager", () => { const configs = await providerSettingsManager.listConfig() expect(configs).toEqual([ { name: "default", id: "default", apiProvider: undefined }, - { name: "test", id: "test-id", apiProvider: "anthropic" }, + { name: "test", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]) }) @@ -426,7 +427,7 @@ describe("ProviderSettingsManager", () => { ) const newConfig: ProviderSettings = { - apiProvider: "vertex", + apiProvider: providerIdentifiers.vertex, apiModelId: "gemini-2.5-flash-preview-05-20", vertexKeyFile: "test-key-file", } @@ -499,7 +500,7 @@ describe("ProviderSettingsManager", () => { ) const newConfig: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } const newConfigWithExtra: ProviderSettings = { @@ -538,7 +539,7 @@ describe("ProviderSettingsManager", () => { currentApiConfigName: "default", apiConfigs: { test: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "old-key", id: "test-id", }, @@ -551,7 +552,7 @@ describe("ProviderSettingsManager", () => { mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) const updatedConfig: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "new-key", } @@ -561,7 +562,7 @@ describe("ProviderSettingsManager", () => { currentApiConfigName: "default", apiConfigs: { test: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "new-key", id: "test-id", }, @@ -614,7 +615,7 @@ describe("ProviderSettingsManager", () => { // Include a legacy provider-specific field (groqApiKey) that is no // longer in the schema — passthrough() must keep it. const retiredConfig = { - apiProvider: "groq", + apiProvider: retiredProviderIdentifiers.groq, apiKey: "legacy-key", apiModelId: "legacy-model", openAiBaseUrl: "https://legacy.example/v1", @@ -647,7 +648,7 @@ describe("ProviderSettingsManager", () => { id: "default", }, test: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, id: "test-id", }, }, @@ -704,7 +705,7 @@ describe("ProviderSettingsManager", () => { currentApiConfigName: "default", apiConfigs: { test: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", id: "test-id", }, @@ -720,7 +721,11 @@ describe("ProviderSettingsManager", () => { const { name, ...providerSettings } = await providerSettingsManager.activateProfile({ name: "test" }) expect(name).toBe("test") - expect(providerSettings).toEqual({ apiProvider: "anthropic", apiKey: "test-key", id: "test-id" }) + expect(providerSettings).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-key", + id: "test-id", + }) // Get the stored config to check the structure. const calls = mockSecrets.store.mock.calls @@ -728,7 +733,7 @@ describe("ProviderSettingsManager", () => { expect(storedConfig.currentApiConfigName).toBe("test") expect(storedConfig.apiConfigs.test).toEqual({ - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", id: "test-id", }) @@ -751,7 +756,7 @@ describe("ProviderSettingsManager", () => { mockSecrets.get.mockResolvedValue( JSON.stringify({ currentApiConfigName: "default", - apiConfigs: { test: { apiProvider: "anthropic", id: "test-id" } }, + apiConfigs: { test: { apiProvider: providerIdentifiers.anthropic, id: "test-id" } }, migrations: { rateLimitSecondsMigrated: true, openAiHeadersMigrated: true, @@ -771,7 +776,7 @@ describe("ProviderSettingsManager", () => { currentApiConfigName: "valid", apiConfigs: { valid: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "valid-key", apiModelId: "claude-3-opus-20240229", id: "valid-id", @@ -818,7 +823,7 @@ describe("ProviderSettingsManager", () => { apiConfigs: { retiredProvider: { id: "retired-id", - apiProvider: "groq", + apiProvider: retiredProviderIdentifiers.groq, apiKey: "legacy-key", apiModelId: "legacy-model", openAiBaseUrl: "https://legacy.example/v1", @@ -861,7 +866,7 @@ describe("ProviderSettingsManager", () => { currentApiConfigName: "valid", apiConfigs: { valid: { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "valid-key", apiModelId: "claude-3-opus-20240229", rateLimitSeconds: 0, @@ -912,7 +917,7 @@ describe("ProviderSettingsManager", () => { apiConfigs: { retired: { id: "retired-id", - apiProvider: "groq", + apiProvider: retiredProviderIdentifiers.groq, apiKey: "legacy-key", apiModelId: "legacy-model", openAiBaseUrl: "https://legacy.example/v1", @@ -940,7 +945,7 @@ describe("ProviderSettingsManager", () => { apiConfigs: { glm: { id: "glm-id", - apiProvider: "zai", + apiProvider: providerIdentifiers.zai, apiModelId: "glm-5.1", modelMaxTokens: 8192, modelMaxThinkingTokens: 2048, @@ -964,7 +969,7 @@ describe("ProviderSettingsManager", () => { apiConfigs: { anthropic: { id: "anthropic-id", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-haiku-20241022", modelMaxTokens: 8192, modelMaxThinkingTokens: 2048, @@ -987,7 +992,7 @@ describe("ProviderSettingsManager", () => { mockSecrets.get.mockResolvedValue( JSON.stringify({ currentApiConfigName: "test", - apiConfigs: { test: { apiProvider: "anthropic", id: "test-id" } }, + apiConfigs: { test: { apiProvider: providerIdentifiers.anthropic, id: "test-id" } }, }), ) @@ -1002,7 +1007,10 @@ describe("ProviderSettingsManager", () => { it("should return true for existing config", async () => { const existingConfig: ProviderProfiles = { currentApiConfigName: "default", - apiConfigs: { default: { id: "default" }, test: { apiProvider: "anthropic", id: "test-id" } }, + apiConfigs: { + default: { id: "default" }, + test: { apiProvider: providerIdentifiers.anthropic, id: "test-id" }, + }, migrations: { rateLimitSecondsMigrated: false }, } @@ -1045,7 +1053,7 @@ describe("ProviderSettingsManager", () => { const cloudProfiles = { "cloud-profile": { id: "cloud-id-1", - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, apiKey: "secret-key", // This should be removed apiModelId: "claude-3-opus-20240229", }, @@ -1060,7 +1068,7 @@ describe("ProviderSettingsManager", () => { const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) expect(storedConfig.apiConfigs["cloud-profile"]).toEqual({ id: "cloud-id-1", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-opus-20240229", // apiKey should be removed }) @@ -1074,7 +1082,7 @@ describe("ProviderSettingsManager", () => { default: { id: "default-id" }, "existing-cloud": { id: "cloud-id-1", - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, apiKey: "existing-secret", apiModelId: "claude-3-haiku-20240307", }, @@ -1087,7 +1095,7 @@ describe("ProviderSettingsManager", () => { const cloudProfiles = { "updated-name": { id: "cloud-id-1", - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, apiKey: "new-secret", // Should be ignored apiModelId: "claude-3-opus-20240229", }, @@ -1102,7 +1110,7 @@ describe("ProviderSettingsManager", () => { const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) expect(storedConfig.apiConfigs["updated-name"]).toEqual({ id: "cloud-id-1", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "existing-secret", // Preserved apiModelId: "claude-3-opus-20240229", // Updated }) @@ -1115,8 +1123,8 @@ describe("ProviderSettingsManager", () => { currentApiConfigName: "default", apiConfigs: { default: { id: "default-id" }, - "cloud-profile-1": { id: "cloud-id-1", apiProvider: "anthropic" as const }, - "cloud-profile-2": { id: "cloud-id-2", apiProvider: "openai" as const }, + "cloud-profile-1": { id: "cloud-id-1", apiProvider: providerIdentifiers.anthropic }, + "cloud-profile-2": { id: "cloud-id-2", apiProvider: providerIdentifiers.openai }, }, cloudProfileIds: ["cloud-id-1", "cloud-id-2"], } @@ -1126,7 +1134,7 @@ describe("ProviderSettingsManager", () => { const cloudProfiles = { "cloud-profile-1": { id: "cloud-id-1", - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, }, // cloud-profile-2 is missing, should be deleted } @@ -1148,7 +1156,7 @@ describe("ProviderSettingsManager", () => { currentApiConfigName: "default", apiConfigs: { default: { id: "default-id" }, - "conflict-name": { id: "local-id", apiProvider: "openai" as const }, + "conflict-name": { id: "local-id", apiProvider: providerIdentifiers.openai }, }, cloudProfileIds: [], } @@ -1158,7 +1166,7 @@ describe("ProviderSettingsManager", () => { const cloudProfiles = { "conflict-name": { id: "cloud-id-1", - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, }, } @@ -1171,11 +1179,11 @@ describe("ProviderSettingsManager", () => { const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) expect(storedConfig.apiConfigs["conflict-name"]).toEqual({ id: "cloud-id-1", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, }) expect(storedConfig.apiConfigs["conflict-name_local"]).toEqual({ id: "local-id", - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, }) expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"]) }) @@ -1185,8 +1193,8 @@ describe("ProviderSettingsManager", () => { currentApiConfigName: "default", apiConfigs: { default: { id: "default-id" }, - "conflict-name": { id: "local-id-1", apiProvider: "openai" as const }, - "conflict-name_local": { id: "local-id-2", apiProvider: "vertex" as const }, + "conflict-name": { id: "local-id-1", apiProvider: providerIdentifiers.openai }, + "conflict-name_local": { id: "local-id-2", apiProvider: providerIdentifiers.vertex }, }, cloudProfileIds: [], } @@ -1196,7 +1204,7 @@ describe("ProviderSettingsManager", () => { const cloudProfiles = { "conflict-name": { id: "cloud-id-1", - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, }, } @@ -1209,15 +1217,15 @@ describe("ProviderSettingsManager", () => { const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) expect(storedConfig.apiConfigs["conflict-name"]).toEqual({ id: "cloud-id-1", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, }) expect(storedConfig.apiConfigs["conflict-name_1"]).toEqual({ id: "local-id-1", - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, }) expect(storedConfig.apiConfigs["conflict-name_local"]).toEqual({ id: "local-id-2", - apiProvider: "vertex", + apiProvider: providerIdentifiers.vertex, }) }) @@ -1226,8 +1234,8 @@ describe("ProviderSettingsManager", () => { currentApiConfigName: "default", apiConfigs: { default: { id: "default-id" }, - "cloud-profile-1": { id: "cloud-id-1", apiProvider: "anthropic" as const }, - "cloud-profile-2": { id: "cloud-id-2", apiProvider: "openai" as const }, + "cloud-profile-1": { id: "cloud-id-1", apiProvider: providerIdentifiers.anthropic }, + "cloud-profile-2": { id: "cloud-id-2", apiProvider: providerIdentifiers.openai }, }, cloudProfileIds: ["cloud-id-1", "cloud-id-2"], } @@ -1263,11 +1271,11 @@ describe("ProviderSettingsManager", () => { const cloudProfiles = { "valid-profile": { id: "cloud-id-1", - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, }, "invalid-profile": { // Missing id - apiProvider: "openai" as const, + apiProvider: providerIdentifiers.openai, }, } @@ -1288,9 +1296,9 @@ describe("ProviderSettingsManager", () => { currentApiConfigName: "default", apiConfigs: { default: { id: "default-id" }, - "keep-cloud": { id: "cloud-id-1", apiProvider: "anthropic" as const, apiKey: "secret1" }, - "delete-cloud": { id: "cloud-id-2", apiProvider: "openai" as const }, - "rename-me": { id: "local-id", apiProvider: "vertex" as const }, + "keep-cloud": { id: "cloud-id-1", apiProvider: providerIdentifiers.anthropic, apiKey: "secret1" }, + "delete-cloud": { id: "cloud-id-2", apiProvider: providerIdentifiers.openai }, + "rename-me": { id: "local-id", apiProvider: providerIdentifiers.vertex }, }, cloudProfileIds: ["cloud-id-1", "cloud-id-2"], } @@ -1300,19 +1308,19 @@ describe("ProviderSettingsManager", () => { const cloudProfiles = { "updated-keep": { id: "cloud-id-1", - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, apiKey: "new-secret", // Should be ignored apiModelId: "claude-3-opus-20240229", }, "rename-me": { id: "cloud-id-3", - apiProvider: "openai" as const, + apiProvider: providerIdentifiers.openai, }, // delete-cloud is missing (should be deleted) // new profile "new-cloud": { id: "cloud-id-4", - apiProvider: "vertex" as const, + apiProvider: providerIdentifiers.vertex, }, } @@ -1331,7 +1339,7 @@ describe("ProviderSettingsManager", () => { // Check updates expect(storedConfig.apiConfigs["updated-keep"]).toEqual({ id: "cloud-id-1", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "secret1", // preserved apiModelId: "claude-3-opus-20240229", }) @@ -1339,17 +1347,17 @@ describe("ProviderSettingsManager", () => { // Check renames expect(storedConfig.apiConfigs["rename-me_local"]).toEqual({ id: "local-id", - apiProvider: "vertex", + apiProvider: providerIdentifiers.vertex, }) expect(storedConfig.apiConfigs["rename-me"]).toEqual({ id: "cloud-id-3", - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, }) // Check new additions expect(storedConfig.apiConfigs["new-cloud"]).toEqual({ id: "cloud-id-4", - apiProvider: "vertex", + apiProvider: providerIdentifiers.vertex, }) expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1", "cloud-id-3", "cloud-id-4"]) @@ -1376,7 +1384,7 @@ describe("ProviderSettingsManager", () => { apiConfigs: { "active-profile": { id: "active-id", - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, apiKey: "old-key", }, }, @@ -1388,7 +1396,7 @@ describe("ProviderSettingsManager", () => { const cloudProfiles = { "active-profile": { id: "active-id", - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-opus-20240229", // Updated setting }, } @@ -1404,8 +1412,8 @@ describe("ProviderSettingsManager", () => { const existingConfig: ProviderProfiles = { currentApiConfigName: "active-profile", apiConfigs: { - "active-profile": { id: "active-id", apiProvider: "anthropic" as const }, - "backup-profile": { id: "backup-id", apiProvider: "openai" as const }, + "active-profile": { id: "active-id", apiProvider: providerIdentifiers.anthropic }, + "backup-profile": { id: "backup-id", apiProvider: providerIdentifiers.openai }, }, cloudProfileIds: ["active-id"], } @@ -1425,7 +1433,7 @@ describe("ProviderSettingsManager", () => { const existingConfig: ProviderProfiles = { currentApiConfigName: "only-profile", apiConfigs: { - "only-profile": { id: "only-id", apiProvider: "anthropic" as const }, + "only-profile": { id: "only-id", apiProvider: providerIdentifiers.anthropic }, }, cloudProfileIds: ["only-id"], } @@ -1449,8 +1457,8 @@ describe("ProviderSettingsManager", () => { const existingConfig: ProviderProfiles = { currentApiConfigName: "local-profile", apiConfigs: { - "local-profile": { id: "local-id", apiProvider: "anthropic" as const }, - "cloud-profile": { id: "cloud-id", apiProvider: "openai" as const }, + "local-profile": { id: "local-id", apiProvider: providerIdentifiers.anthropic }, + "cloud-profile": { id: "cloud-id", apiProvider: providerIdentifiers.openai }, }, cloudProfileIds: ["cloud-id"], } @@ -1460,7 +1468,7 @@ describe("ProviderSettingsManager", () => { const cloudProfiles = { "cloud-profile": { id: "cloud-id", - apiProvider: "openai" as const, + apiProvider: providerIdentifiers.openai, apiModelId: "gpt-4", // Updated cloud profile }, } diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 94cbc934cf..58104009d8 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -18,6 +18,7 @@ import { CustomModesManager } from "../CustomModesManager" import { safeWriteJson } from "../../../utils/safeWriteJson" import type { Mock } from "vitest" +import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" vi.mock("vscode", () => ({ workspace: { @@ -72,7 +73,10 @@ vi.mock("../../../api", () => ({ buildApiHandler: vi.fn().mockImplementation((config) => { // Return different model info based on the provider and model const getModelInfo = () => { - if (config.apiProvider === "anthropic" && config.apiModelId === "claude-3-5-sonnet-20241022") { + if ( + config.apiProvider === providerIdentifiers.anthropic && + config.apiModelId === "claude-3-5-sonnet-20241022" + ) { return { id: "claude-3-5-sonnet-20241022", info: { @@ -177,7 +181,9 @@ describe("importExport", () => { const mockFileContent = JSON.stringify({ providerProfiles: { currentApiConfigName: "test", - apiConfigs: { test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" } }, + apiConfigs: { + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, + }, }, globalSettings: { mode: "code", autoApprovalEnabled: true }, }) @@ -186,14 +192,14 @@ describe("importExport", () => { const previousProviderProfiles = { currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, } mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "test", id: "test-id", apiProvider: "openai" as ProviderName }, - { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + { name: "test", id: "test-id", apiProvider: providerIdentifiers.openai }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, ]) mockContextProxy.export.mockResolvedValue({ mode: "code" }) @@ -211,8 +217,8 @@ describe("importExport", () => { expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({ currentApiConfigName: "test", apiConfigs: { - default: { apiProvider: "anthropic" as ProviderName, id: "default-id" }, - test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" }, + default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" }, + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, }, modeApiConfigs: {}, }) @@ -221,8 +227,8 @@ describe("importExport", () => { expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "test") expect(mockContextProxy.setValue).toHaveBeenCalledWith("listApiConfigMeta", [ - { name: "test", id: "test-id", apiProvider: "openai" as ProviderName }, - { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + { name: "test", id: "test-id", apiProvider: providerIdentifiers.openai }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, ]) }) @@ -255,7 +261,9 @@ describe("importExport", () => { const mockFileContent = JSON.stringify({ providerProfiles: { currentApiConfigName: "test", - apiConfigs: { test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" } }, + apiConfigs: { + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, + }, }, }) @@ -263,14 +271,14 @@ describe("importExport", () => { const previousProviderProfiles = { currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, } mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "test", id: "test-id", apiProvider: "openai" as ProviderName }, - { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + { name: "test", id: "test-id", apiProvider: providerIdentifiers.openai }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, ]) mockContextProxy.export.mockResolvedValue({ mode: "code" }) @@ -287,8 +295,8 @@ describe("importExport", () => { expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({ currentApiConfigName: "test", apiConfigs: { - default: { apiProvider: "anthropic" as ProviderName, id: "default-id" }, - test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" }, + default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" }, + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, }, modeApiConfigs: {}, }) @@ -297,8 +305,8 @@ describe("importExport", () => { expect(mockContextProxy.setValues).toHaveBeenCalledWith({}) expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "test") expect(mockContextProxy.setValue).toHaveBeenCalledWith("listApiConfigMeta", [ - { name: "test", id: "test-id", apiProvider: "openai" as ProviderName }, - { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + { name: "test", id: "test-id", apiProvider: providerIdentifiers.openai }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, ]) }) @@ -338,7 +346,10 @@ describe("importExport", () => { it("should not clobber existing api configs", async () => { const providerSettingsManager = new ProviderSettingsManager(mockExtensionContext) - await providerSettingsManager.saveConfig("openai", { apiProvider: "openai", id: "openai" }) + await providerSettingsManager.saveConfig("openai", { + apiProvider: providerIdentifiers.openai, + id: "openai", + }) const configs = await providerSettingsManager.listConfig() expect(configs[0].name).toBe("default") @@ -349,7 +360,7 @@ describe("importExport", () => { globalSettings: { mode: "code" }, providerProfiles: { currentApiConfigName: "anthropic", - apiConfigs: { default: { apiProvider: "anthropic" as const, id: "anthropic" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "anthropic" } }, }, }) @@ -412,7 +423,9 @@ describe("importExport", () => { const mockFileContent = JSON.stringify({ providerProfiles: { currentApiConfigName: "test", - apiConfigs: { test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" } }, + apiConfigs: { + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, + }, }, globalSettings: { mode: "code", autoApprovalEnabled: true }, }) @@ -422,13 +435,13 @@ describe("importExport", () => { const previousProviderProfiles = { currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, } mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "test", id: "test-id", apiProvider: "openai" as ProviderName }, - { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + { name: "test", id: "test-id", apiProvider: providerIdentifiers.openai }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, ]) mockContextProxy.export.mockResolvedValue({ mode: "code" }) @@ -447,8 +460,8 @@ describe("importExport", () => { expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({ currentApiConfigName: "test", apiConfigs: { - default: { apiProvider: "anthropic" as ProviderName, id: "default-id" }, - test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" }, + default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" }, + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, }, modeApiConfigs: {}, }) @@ -499,7 +512,7 @@ describe("importExport", () => { currentApiConfigName: "openai-provider", apiConfigs: { "openai-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiModelId: "gpt-4", id: "openai-id", apiKey: "test-key", @@ -514,13 +527,13 @@ describe("importExport", () => { const previousProviderProfiles = { currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, } mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "openai-provider", id: "openai-id", apiProvider: "openai" as ProviderName }, - { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + { name: "openai-provider", id: "openai-id", apiProvider: providerIdentifiers.openai }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, ]) mockContextProxy.export.mockResolvedValue({ mode: "code" }) @@ -538,9 +551,9 @@ describe("importExport", () => { expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({ currentApiConfigName: "openai-provider", apiConfigs: { - default: { apiProvider: "anthropic" as ProviderName, id: "default-id" }, + default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" }, "openai-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiModelId: "gpt-4", apiKey: "test-key", id: "openai-id", @@ -563,7 +576,7 @@ describe("importExport", () => { currentApiConfigName: "valid-profile", apiConfigs: { "valid-profile": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "valid-id", }, @@ -581,11 +594,11 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, - { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, ]) const result = await importSettings({ @@ -623,7 +636,7 @@ describe("importExport", () => { currentApiConfigName: "valid-profile", apiConfigs: { "valid-profile": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "valid-id", }, @@ -641,10 +654,10 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, ]) const result = await importSettings({ @@ -695,7 +708,7 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) const result = await importSettings({ @@ -719,7 +732,7 @@ describe("importExport", () => { currentApiConfigName: "valid-profile", apiConfigs: { "valid-profile": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "valid-id", }, @@ -738,10 +751,10 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, ]) const seenImportedAt: Array = [] @@ -800,7 +813,7 @@ describe("importExport", () => { currentApiConfigName: "valid-profile", apiConfigs: { "valid-profile": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "valid-id", }, @@ -814,10 +827,10 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, ]) const seenImportedAt: Array = [] @@ -851,12 +864,12 @@ describe("importExport", () => { currentApiConfigName: "anthropic-profile", apiConfigs: { "anthropic-profile": { - apiProvider: "anthropic" as ProviderName, + apiProvider: providerIdentifiers.anthropic, anthropicApiKey: "key-1", id: "anthropic-id", }, "openai-profile": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiKey: "key-2", id: "openai-id", }, @@ -879,11 +892,11 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "anthropic-profile", id: "anthropic-id", apiProvider: "anthropic" as ProviderName }, - { name: "openai-profile", id: "openai-id", apiProvider: "openai" as ProviderName }, + { name: "anthropic-profile", id: "anthropic-id", apiProvider: providerIdentifiers.anthropic }, + { name: "openai-profile", id: "openai-id", apiProvider: providerIdentifiers.openai }, ]) const result = await importSettings({ @@ -919,7 +932,7 @@ describe("importExport", () => { currentApiConfigName: "router-profile", apiConfigs: { "router-profile": { - apiProvider: "roo", + apiProvider: retiredProviderIdentifiers.roo, apiModelId: "roo/code-supernova", rooApiKey: "router-key", id: "router-id", @@ -933,7 +946,7 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) const result = await importSettings({ @@ -964,7 +977,7 @@ describe("importExport", () => { id: "invalid-current-id", }, "valid-fallback-profile": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "fallback-id", }, @@ -977,10 +990,10 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "valid-fallback-profile", id: "fallback-id", apiProvider: "openai" as ProviderName }, + { name: "valid-fallback-profile", id: "fallback-id", apiProvider: providerIdentifiers.openai }, ]) const result = await importSettings({ @@ -1040,7 +1053,7 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "existing-profile", apiConfigs: { - "existing-profile": { apiProvider: "anthropic" as ProviderName, id: "existing-id" }, + "existing-profile": { apiProvider: providerIdentifiers.anthropic, id: "existing-id" }, }, }) @@ -1062,7 +1075,7 @@ describe("importExport", () => { currentApiConfigName: "valid-profile", apiConfigs: { "valid-profile": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "valid-id", }, @@ -1086,10 +1099,10 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, ]) const mockProvider = { @@ -1135,14 +1148,14 @@ describe("importExport", () => { currentApiConfigName: "valid-profile", apiConfigs: { "valid-profile": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "valid-id", }, }, }, globalSettings: { - imageGenerationProvider: "roo", + imageGenerationProvider: retiredProviderIdentifiers.roo, openRouterImageGenerationSelectedModel: "openrouter/model-1", customInstructions: "Keep this setting", }, @@ -1151,10 +1164,10 @@ describe("importExport", () => { ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, ]) const result = await importSettings({ @@ -1185,7 +1198,7 @@ describe("importExport", () => { currentApiConfigName: "valid-profile", apiConfigs: { "valid-profile": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "valid-id", }, @@ -1202,10 +1215,10 @@ describe("importExport", () => { ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, ]) const result = await importSettings({ @@ -1237,7 +1250,7 @@ describe("importExport", () => { currentApiConfigName: "valid-profile", apiConfigs: { "valid-profile": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "valid-id", }, @@ -1259,10 +1272,10 @@ describe("importExport", () => { ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, ]) const result = await importSettings({ @@ -1288,7 +1301,7 @@ describe("importExport", () => { currentApiConfigName: "valid-profile", apiConfigs: { "valid-profile": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "valid-id", }, @@ -1303,10 +1316,10 @@ describe("importExport", () => { ;(fs.access as Mock).mockResolvedValue(undefined) mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, ]) const mockProvider = { @@ -1363,12 +1376,12 @@ describe("importExport", () => { it("should export settings to the selected file location", async () => { ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) const mockProviderProfiles = { currentApiConfigName: "test", - apiConfigs: { test: { apiProvider: "openai" as ProviderName, id: "test-id" } }, + apiConfigs: { test: { apiProvider: providerIdentifiers.openai, id: "test-id" } }, migrations: { rateLimitSecondsMigrated: false }, } @@ -1390,7 +1403,7 @@ describe("importExport", () => { expect(mockContextProxy.export).toHaveBeenCalled() expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true }) - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", { + expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, }) @@ -1398,12 +1411,12 @@ describe("importExport", () => { it("should include globalSettings when allowedMaxRequests is null", async () => { ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) const mockProviderProfiles = { currentApiConfigName: "test", - apiConfigs: { test: { apiProvider: "openai" as ProviderName, id: "test-id" } }, + apiConfigs: { test: { apiProvider: providerIdentifiers.openai, id: "test-id" } }, migrations: { rateLimitSecondsMigrated: false }, } @@ -1422,7 +1435,7 @@ describe("importExport", () => { contextProxy: mockContextProxy, }) - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", { + expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, }) @@ -1430,12 +1443,12 @@ describe("importExport", () => { it("should handle errors during the export process", async () => { ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "test", - apiConfigs: { test: { apiProvider: "openai" as ProviderName, id: "test-id" } }, + apiConfigs: { test: { apiProvider: providerIdentifiers.openai, id: "test-id" } }, migrations: { rateLimitSecondsMigrated: false }, }) @@ -1460,12 +1473,12 @@ describe("importExport", () => { it("should handle errors during directory creation", async () => { ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "test", - apiConfigs: { test: { apiProvider: "openai" as ProviderName, id: "test-id" } }, + apiConfigs: { test: { apiProvider: providerIdentifiers.openai, id: "test-id" } }, migrations: { rateLimitSecondsMigrated: false }, }) @@ -1497,25 +1510,25 @@ describe("importExport", () => { defaultUri: expect.anything(), }) - expect(vscode.Uri.file).toHaveBeenCalledWith(path.join("/mock/home", "Downloads", "roo-code-settings.json")) + expect(vscode.Uri.file).toHaveBeenCalledWith(path.join("/mock/home", "Downloads", "zoo-code-settings.json")) }) describe("codebase indexing export", () => { it("should export correct base URL for OpenAI Compatible provider", async () => { ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) const mockProviderProfiles = { currentApiConfigName: "openai-compatible-provider", apiConfigs: { "openai-compatible-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "openai-compatible-id", // Remove OpenAI Compatible settings from provider profile }, "ollama-provider": { - apiProvider: "ollama" as ProviderName, + apiProvider: providerIdentifiers.ollama, id: "ollama-id", codebaseIndexOllamaBaseUrl: "http://localhost:11434", }, @@ -1545,7 +1558,7 @@ describe("importExport", () => { contextProxy: mockContextProxy, }) - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", { + expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, }) @@ -1553,14 +1566,14 @@ describe("importExport", () => { it("should export model dimension for OpenAI Compatible provider", async () => { ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) const mockProviderProfiles = { currentApiConfigName: "test-provider", apiConfigs: { "test-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "test-id", // Remove OpenAI Compatible settings from provider profile }, @@ -1600,24 +1613,24 @@ describe("importExport", () => { it("should not mix settings between different providers", async () => { ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) const mockProviderProfiles = { currentApiConfigName: "openai-compatible-provider", apiConfigs: { "openai-compatible-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "openai-compatible-id", // Remove OpenAI Compatible settings from provider profile }, "ollama-provider": { - apiProvider: "ollama" as ProviderName, + apiProvider: providerIdentifiers.ollama, id: "ollama-id", codebaseIndexOllamaBaseUrl: "http://localhost:11434", }, "anthropic-provider": { - apiProvider: "anthropic" as ProviderName, + apiProvider: providerIdentifiers.anthropic, id: "anthropic-id", }, }, @@ -1660,14 +1673,14 @@ describe("importExport", () => { it("should handle missing provider-specific settings gracefully", async () => { ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) const mockProviderProfiles = { currentApiConfigName: "incomplete-provider", apiConfigs: { "incomplete-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "incomplete-id", // Missing codebaseIndexOpenAiCompatibleBaseUrl and dimension }, @@ -1698,7 +1711,7 @@ describe("importExport", () => { }) // Should not throw an error and should preserve original settings - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", { + expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, // Should remain unchanged }) @@ -1706,14 +1719,14 @@ describe("importExport", () => { it("should maintain backward compatibility with existing exports", async () => { ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) const mockProviderProfiles = { currentApiConfigName: "openai-provider", apiConfigs: { "openai-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "openai-id", // Regular OpenAI provider without OpenAI Compatible settings }, @@ -1725,7 +1738,7 @@ describe("importExport", () => { mode: "code", codebaseIndexConfig: { codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai" as const, // Not openai-compatible + codebaseIndexEmbedderProvider: providerIdentifiers.openai, // Not openai-compatible codebaseIndexEmbedderModelId: "text-embedding-ada-002", codebaseIndexEmbedderBaseUrl: "https://api.openai.com/v1", }, @@ -1741,7 +1754,7 @@ describe("importExport", () => { }) // Should not modify settings for non-openai-compatible providers - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", { + expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, // Should remain unchanged }) @@ -1749,14 +1762,14 @@ describe("importExport", () => { it("should handle missing current provider gracefully", async () => { ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) const mockProviderProfiles = { currentApiConfigName: "nonexistent-provider", apiConfigs: { "other-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "other-id", }, }, @@ -1786,7 +1799,7 @@ describe("importExport", () => { }) // Should not throw an error and should preserve original settings - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", { + expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, // Should remain unchanged }) @@ -1802,7 +1815,7 @@ describe("importExport", () => { currentApiConfigName: "openai-compatible-provider", apiConfigs: { "openai-compatible-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "openai-compatible-id", // Provider-specific settings remain in provider profile codebaseIndexOpenAiCompatibleBaseUrl: "https://old-url.example.com/v1", @@ -1829,7 +1842,7 @@ describe("importExport", () => { const previousProviderProfiles = { currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, } mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) @@ -1837,9 +1850,9 @@ describe("importExport", () => { { name: "openai-compatible-provider", id: "openai-compatible-id", - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, }, - { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, ]) const result = await importSettings({ @@ -1877,7 +1890,7 @@ describe("importExport", () => { currentApiConfigName: "openai-compatible-provider", apiConfigs: { "openai-compatible-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "openai-compatible-id", }, }, @@ -1898,7 +1911,7 @@ describe("importExport", () => { const previousProviderProfiles = { currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, } mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) @@ -1906,7 +1919,7 @@ describe("importExport", () => { { name: "openai-compatible-provider", id: "openai-compatible-id", - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, }, ]) @@ -1928,7 +1941,7 @@ describe("importExport", () => { currentApiConfigName: "anthropic-provider", apiConfigs: { "anthropic-provider": { - apiProvider: "anthropic" as ProviderName, + apiProvider: providerIdentifiers.anthropic, id: "anthropic-id", }, }, @@ -1938,7 +1951,7 @@ describe("importExport", () => { mode: "code", codebaseIndexConfig: { codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai" as const, // Not openai-compatible + codebaseIndexEmbedderProvider: providerIdentifiers.openai, // Not openai-compatible codebaseIndexEmbedderModelId: "text-embedding-ada-002", codebaseIndexEmbedderBaseUrl: "https://api.openai.com/v1", codebaseIndexEmbedderModelDimension: 1536, @@ -1950,12 +1963,12 @@ describe("importExport", () => { const previousProviderProfiles = { currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, } mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "anthropic-provider", id: "anthropic-id", apiProvider: "anthropic" as ProviderName }, + { name: "anthropic-provider", id: "anthropic-id", apiProvider: providerIdentifiers.anthropic }, ]) const result = await importSettings({ @@ -1986,7 +1999,7 @@ describe("importExport", () => { currentApiConfigName: "test-openai-compatible", apiConfigs: { "test-openai-compatible": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "test-id", // Remove OpenAI Compatible settings from provider profile }, @@ -2040,10 +2053,10 @@ describe("importExport", () => { clearAllMocks() mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "test-openai-compatible", id: "test-id", apiProvider: "openai" as ProviderName }, + { name: "test-openai-compatible", id: "test-id", apiProvider: providerIdentifiers.openai }, ]) // Step 7: Import the settings back @@ -2079,7 +2092,7 @@ describe("importExport", () => { currentApiConfigName: "test-openai-compatible", apiConfigs: { "test-openai-compatible": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "test-id", // Remove OpenAI Compatible settings from provider profile }, @@ -2128,10 +2141,10 @@ describe("importExport", () => { clearAllMocks() mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", - apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, }) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "test-openai-compatible", id: "test-id", apiProvider: "openai" as ProviderName }, + { name: "test-openai-compatible", id: "test-id", apiProvider: providerIdentifiers.openai }, ]) // Import the settings back @@ -2154,7 +2167,7 @@ describe("importExport", () => { currentApiConfigName: "test-openai-compatible", apiConfigs: { "test-openai-compatible": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "test-id", // Remove OpenAI Compatible settings from provider profile }, @@ -2213,13 +2226,13 @@ describe("importExport", () => { currentApiConfigName: "provider-a", apiConfigs: { "provider-a": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "provider-a-id", codebaseIndexOpenAiCompatibleBaseUrl: "https://api-a.example.com/v1", codebaseIndexOpenAiCompatibleModelDimension: 1536, }, "provider-b": { - apiProvider: "anthropic" as ProviderName, + apiProvider: providerIdentifiers.anthropic, id: "provider-b-id", }, }, @@ -2242,7 +2255,7 @@ describe("importExport", () => { currentApiConfigName: "provider-b", // Different from exported settings! apiConfigs: { "provider-b": { - apiProvider: "anthropic" as ProviderName, + apiProvider: providerIdentifiers.anthropic, id: "provider-b-id", }, }, @@ -2254,8 +2267,8 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue(currentProviderProfiles) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "provider-a", id: "provider-a-id", apiProvider: "openai" as ProviderName }, - { name: "provider-b", id: "provider-b-id", apiProvider: "anthropic" as ProviderName }, + { name: "provider-a", id: "provider-a-id", apiProvider: providerIdentifiers.openai }, + { name: "provider-b", id: "provider-b-id", apiProvider: providerIdentifiers.anthropic }, ]) // Step 4: Import the settings @@ -2292,12 +2305,12 @@ describe("importExport", () => { currentApiConfigName: "openai-compatible-provider", apiConfigs: { "openai-compatible-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "openai-compatible-id", // NO OpenAI Compatible settings here in the fixed version }, "anthropic-provider": { - apiProvider: "anthropic" as ProviderName, + apiProvider: providerIdentifiers.anthropic, id: "anthropic-id", }, }, @@ -2322,7 +2335,7 @@ describe("importExport", () => { currentApiConfigName: "anthropic-provider", apiConfigs: { "anthropic-provider": { - apiProvider: "anthropic" as ProviderName, + apiProvider: providerIdentifiers.anthropic, id: "anthropic-id", }, }, @@ -2336,9 +2349,9 @@ describe("importExport", () => { { name: "openai-compatible-provider", id: "openai-compatible-id", - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, }, - { name: "anthropic-provider", id: "anthropic-id", apiProvider: "anthropic" as ProviderName }, + { name: "anthropic-provider", id: "anthropic-id", apiProvider: providerIdentifiers.anthropic }, ]) const importResult = await importSettings({ @@ -2377,11 +2390,11 @@ describe("importExport", () => { currentApiConfigName: "anthropic-provider", apiConfigs: { "anthropic-provider": { - apiProvider: "anthropic" as ProviderName, + apiProvider: providerIdentifiers.anthropic, id: "anthropic-id", }, "openai-compatible-provider": { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "openai-compatible-id", // NO OpenAI Compatible settings in provider profiles }, @@ -2407,7 +2420,7 @@ describe("importExport", () => { currentApiConfigName: "default", apiConfigs: { default: { - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, id: "default-id", }, }, @@ -2418,13 +2431,13 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue(currentProviderProfiles) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "anthropic-provider", id: "anthropic-id", apiProvider: "anthropic" as ProviderName }, + { name: "anthropic-provider", id: "anthropic-id", apiProvider: providerIdentifiers.anthropic }, { name: "openai-compatible-provider", id: "openai-compatible-id", - apiProvider: "openai" as ProviderName, + apiProvider: providerIdentifiers.openai, }, - { name: "default", id: "default-id", apiProvider: "openai" as ProviderName }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.openai }, ]) const importResult = await importSettings({ @@ -2459,7 +2472,7 @@ describe("importExport", () => { // when the OpenAI Compatible settings are stored in global state via contextProxy ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) // Set up provider profiles - note that the OpenAI Compatible provider does NOT have @@ -2469,7 +2482,7 @@ describe("importExport", () => { currentApiConfigName: "openrouter-provider", // Current provider is OpenRouter apiConfigs: { "openrouter-provider": { - apiProvider: "openrouter" as ProviderName, + apiProvider: providerIdentifiers.openrouter, id: "openrouter-id", // OpenRouter doesn't have OpenAI Compatible fields }, @@ -2533,7 +2546,7 @@ describe("importExport", () => { // Using deepseek provider which uses apiModelId and has supportsReasoningBudget: false ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ - fsPath: "/mock/path/roo-code-settings.json", + fsPath: "/mock/path/zoo-code-settings.json", }) // Use a real ProviderSettingsManager instance to test the actual filtering logic @@ -2544,7 +2557,7 @@ describe("importExport", () => { // Save a deepseek provider config with token fields await realProviderSettingsManager.saveConfig(providerName, { - apiProvider: "deepseek" as ProviderName, + apiProvider: providerIdentifiers.deepseek, apiModelId: modelId, id: providerId, deepSeekApiKey: "test-key", diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index b5fd5fdf98..645187065b 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -270,7 +270,7 @@ export async function importSettingsFromPath( */ export const importSettings = async ({ providerSettingsManager, contextProxy, customModesManager }: ImportOptions) => { // Use the last export path as a sensible default, falling back to Downloads - const defaultUri = resolveDefaultSaveUri(contextProxy, "lastSettingsExportPath", "roo-code-settings.json", { + const defaultUri = resolveDefaultSaveUri(contextProxy, "lastSettingsExportPath", "zoo-code-settings.json", { useWorkspace: false, fallbackDir: path.join(os.homedir(), "Downloads"), }) @@ -310,7 +310,7 @@ export const importSettingsFromFile = async ( } export const exportSettings = async ({ providerSettingsManager, contextProxy }: ExportOptions) => { - const defaultUri = await resolveDefaultSaveUri(contextProxy, "lastSettingsExportPath", "roo-code-settings.json", { + const defaultUri = await resolveDefaultSaveUri(contextProxy, "lastSettingsExportPath", "zoo-code-settings.json", { useWorkspace: false, fallbackDir: path.join(os.homedir(), "Downloads"), }) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index c6c3c6910f..075d21474f 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1,16 +1,29 @@ import * as fs from "fs/promises" import * as fsSync from "fs" import * as path from "path" +import crypto from "crypto" +import deepEqual from "fast-deep-equal" import type { HistoryItem } from "@roo-code/types" import { GlobalFileNames } from "../../shared/globalFileNames" -import { safeWriteJson } from "../../utils/safeWriteJson" +import { LOCK_STALE_MS, safeWriteJson } from "../../utils/safeWriteJson" import { getStorageBasePath } from "../../utils/storage" /** Valid status values for a task's HistoryItem. */ export type HistoryItemStatus = NonNullable +export class DeltaRejectedError extends Error { + constructor( + public readonly taskId: string, + public readonly diskStatus: HistoryItemStatus, + public readonly attemptedStatus: HistoryItemStatus, + ) { + super(`Delta rejected for task ${taskId}: disk status ${diskStatus} rejects transition to ${attemptedStatus}`) + this.name = "DeltaRejectedError" + } +} + const VALID_TRANSITIONS: Record = { active: ["delegated", "completed", "interrupted"], delegated: ["active"], @@ -32,12 +45,58 @@ export function assertValidTransition(from: HistoryItemStatus | undefined, to: H } /** - * Index file format for fast startup reads. + * Build a `safeWriteJson` merge callback that applies only `delta` to the + * current disk state, preserving fields written by another process. */ -interface HistoryIndex { - version: number - updatedAt: number - entries: HistoryItem[] +function mergeWithDisk(delta: Partial): (existing: unknown, incoming: unknown) => unknown { + return (existing, incoming) => { + if (!existing || typeof existing !== "object" || !("id" in existing)) { + return incoming + } + const disk = existing as HistoryItem + if (delta.status !== undefined) { + const diskStatus: HistoryItemStatus = disk.status ?? "active" + if (delta.status !== diskStatus) { + const validTargets = VALID_TRANSITIONS[diskStatus] + if (!validTargets?.includes(delta.status as HistoryItemStatus)) { + throw new DeltaRejectedError(disk.id, diskStatus, delta.status as HistoryItemStatus) + } + } + } + const merged = { ...disk, ...delta } + if (delta.childIds && disk.childIds) { + merged.childIds = [...new Set([...disk.childIds, ...delta.childIds])] + } + return merged + } +} + +/** + * Durable intent for the one repair that spans an active delegated child and + * its parent. Task files remain authoritative; this file only records the + * guarded target transition that must be completed after a crash. + */ +interface DelegationRepairIntent { + version: 1 + operationId: string + parentTaskId: string + childTaskId: string + expected: { + parent: { + status: "delegated" + awaitingChildId: string + delegatedToId?: string + } + child: { + status: "active" + parentTaskId?: string + rootTaskId?: string + } + } + target: { + childStatus: "interrupted" + parentStatus: "active" + } } /** @@ -45,12 +104,14 @@ interface HistoryIndex { * * Each task's HistoryItem is stored as an individual JSON file in its * existing task directory (`globalStorage/tasks//history_item.json`). - * A single index file (`globalStorage/tasks/_index.json`) is maintained - * as a cache for fast list reads at startup. + * There is no shared index file. Reads scan the task directories. * - * Cross-process safety comes from `safeWriteJson`'s `proper-lockfile` - * on per-task file writes. Within a single extension host process, - * an in-process write lock serializes mutations. + * Cross-process safety for per-task files comes from `safeWriteJson`'s + * `proper-lockfile` with a `merge` callback: each write reads the + * current file under the advisory lock and merges incoming fields, so + * a concurrent writer's changes are preserved rather than silently + * dropped. Within a single extension host process, an in-process write + * lock serializes mutations. */ /** * Options for TaskHistoryStore constructor. @@ -68,8 +129,8 @@ export class TaskHistoryStore { private readonly globalStoragePath: string private readonly onWrite?: (items: HistoryItem[]) => Promise private cache: Map = new Map() + private taskFileMtimes: Map = new Map() private writeLock: Promise = Promise.resolve() - private indexWriteTimer: ReturnType | null = null private fsWatcher: fsSync.FSWatcher | null = null private reconcileTimer: ReturnType | null = null private disposed = false @@ -81,9 +142,6 @@ export class TaskHistoryStore { public readonly initialized: Promise private resolveInitialized!: () => void - /** Debounce window for index writes in milliseconds. */ - private static readonly INDEX_WRITE_DEBOUNCE_MS = 2000 - /** Periodic reconciliation interval in milliseconds. */ private static readonly RECONCILE_INTERVAL_MS = 5 * 60 * 1000 @@ -98,21 +156,29 @@ export class TaskHistoryStore { // ────────────────────────────── Lifecycle ────────────────────────────── /** - * Load index, reconcile if needed, start watchers. + * Scan task files, reconcile delegation state, start watchers. */ async initialize(): Promise { try { const tasksDir = await this.getTasksDir() await fs.mkdir(tasksDir, { recursive: true }) - // 1. Load existing index into the cache - await this.loadIndex() + // 1. Scan task directories to populate the cache + await this.reconcile({ forceRefresh: true }) + // Capture which active tasks were present in persisted state before replay can + // change any statuses. Reconciliation must not treat a replay-repaired parent + // as an orphaned active child in the same startup pass. + const persistedActiveIds = this.getPersistedActiveIds() - // 2. Reconcile cache against actual task directories on disk - await this.reconcile() + // 2. Complete any two-record repair interrupted after its intent was durable. + try { + await this.replayDelegationRepairIntent() + } catch (error) { + console.error("[TaskHistoryStore] Failed to replay delegation repair intent:", error) + } // 3. Repair delegation inconsistencies left by a previous crash - await this.reconcileDelegationState() + await this.reconcileDelegationState(persistedActiveIds) // 4. Start fs.watch for cross-instance reactivity this.startWatcher() @@ -131,11 +197,6 @@ export class TaskHistoryStore { dispose(): void { this.disposed = true - if (this.indexWriteTimer) { - clearTimeout(this.indexWriteTimer) - this.indexWriteTimer = null - } - if (this.reconcileTimer) { clearTimeout(this.reconcileTimer) this.reconcileTimer = null @@ -145,11 +206,6 @@ export class TaskHistoryStore { this.fsWatcher.close() this.fsWatcher = null } - - // Synchronously flush the index (best-effort) - this.flushIndex().catch((err) => { - console.error("[TaskHistoryStore] Error flushing index on dispose:", err) - }) } // ────────────────────────────── Reads ────────────────────────────── @@ -180,8 +236,8 @@ export class TaskHistoryStore { /** * Insert or update a history item. * - * Writes the per-task file immediately (source of truth), - * updates the in-memory Map, and schedules a debounced index write. + * Writes the per-task file immediately (source of truth) + * and updates the in-memory cache. */ async upsert(item: HistoryItem): Promise { return this.withLock(() => this.upsertCore(item)) @@ -208,20 +264,40 @@ export class TaskHistoryStore { if (!options.skipTransitionCheck && existing && item.status !== undefined) { const normalizedExisting: HistoryItemStatus = existing.status ?? "active" if (item.status !== normalizedExisting) { - assertValidTransition(existing.status, item.status) + try { + assertValidTransition(existing.status, item.status) + } catch (cacheError) { + // Cache may be stale from a peer write. Re-read disk + // under the store lock before rejecting the transition. + const diskItem = await this.readTaskFile(item.id) + if (!diskItem) { + throw cacheError + } + assertValidTransition(diskItem.status, item.status) + } } } // Merge: preserve existing metadata unless explicitly overwritten const merged = existing ? { ...existing, ...item } : item - // Write per-task file (source of truth) - await this.writeTaskFile(merged) + const delta = existing ? this.buildDelta(item.id, existing, item) : { ...item } + let written: HistoryItem + try { + written = await this.writeTaskFile(merged, delta) + } catch (error) { + if (error instanceof DeltaRejectedError) { + const diskItem = await this.readTaskFile(item.id) + if (diskItem) { + this.cache.set(item.id, diskItem) + } + throw error + } + throw error + } - // Update in-memory cache - this.cache.set(merged.id, merged) - // Schedule debounced index write - this.scheduleIndexWrite() + // Update in-memory cache with what was actually persisted + this.cache.set(written.id, written) const all = this.getAll() @@ -239,6 +315,7 @@ export class TaskHistoryStore { async delete(taskId: string): Promise { return this.withLock(async () => { this.cache.delete(taskId) + this.taskFileMtimes.delete(taskId) // Remove per-task file (best-effort) try { @@ -248,8 +325,6 @@ export class TaskHistoryStore { // File may already be deleted } - this.scheduleIndexWrite() - // Call onWrite callback inside the lock for serialized write-through if (this.onWrite) { await this.onWrite(this.getAll()) @@ -264,6 +339,7 @@ export class TaskHistoryStore { return this.withLock(async () => { for (const taskId of taskIds) { this.cache.delete(taskId) + this.taskFileMtimes.delete(taskId) try { const filePath = await this.getTaskFilePath(taskId) @@ -273,8 +349,6 @@ export class TaskHistoryStore { } } - this.scheduleIndexWrite() - // Call onWrite callback inside the lock for serialized write-through if (this.onWrite) { await this.onWrite(this.getAll()) @@ -285,12 +359,12 @@ export class TaskHistoryStore { // ────────────────────────────── Reconciliation ────────────────────────────── /** - * Scan task directories vs index and fix any drift. + * Scan task directories and fix any drift between disk and cache. * * - Tasks on disk but missing from cache: read and add * - Tasks in cache but missing from disk: remove */ - async reconcile(): Promise { + async reconcile(options: { forceRefresh?: boolean } = {}): Promise { // Run through the write lock to prevent interleaving with upsert/delete return this.withLock(async () => { const tasksDir = await this.getTasksDir() @@ -302,39 +376,58 @@ export class TaskHistoryStore { return // tasks dir doesn't exist yet } - // Filter out the index file and hidden files + // Filter out hidden and reserved names const taskDirNames = dirEntries.filter((name) => !name.startsWith("_") && !name.startsWith(".")) const onDiskIds = new Set(taskDirNames) const cacheIds = new Set(this.cache.keys()) - let changed = false + const liveIds = new Set() - // Tasks on disk but not in cache: read their history_item.json for (const taskId of onDiskIds) { - if (!cacheIds.has(taskId)) { - try { - const item = await this.readTaskFile(taskId) - if (item) { + try { + const taskFilePath = await this.getTaskFilePath(taskId) + const { mtimeMs } = await fs.stat(taskFilePath) + liveIds.add(taskId) + if ( + !options.forceRefresh && + this.cache.has(taskId) && + this.taskFileMtimes.get(taskId) === mtimeMs + ) { + continue + } + + const item = await this.readTaskFile(taskId) + if (item?.id === taskId) { + const previous = this.cache.get(taskId) + this.taskFileMtimes.set(taskId, mtimeMs) + if (!deepEqual(previous, item)) { this.cache.set(taskId, item) - changed = true + } + } + } catch { + // File may be temporarily absent during a peer's atomic + // rename window in safeWriteJson. The advisory lock is + // held for the entire write, so its presence means a + // write is in progress — keep the task live. + try { + const lockPath = (await this.getTaskFilePath(taskId)) + ".lock" + const lockStat = await fs.stat(lockPath) + if (Date.now() - lockStat.mtimeMs < LOCK_STALE_MS) { + liveIds.add(taskId) } } catch { - // Corrupted or missing file, skip + // No lock file — file is genuinely absent } } } - // Tasks in cache but not on disk: remove from cache + // Evict tasks whose history_item.json no longer exists for (const taskId of cacheIds) { - if (!onDiskIds.has(taskId)) { + if (!liveIds.has(taskId)) { this.cache.delete(taskId) - changed = true + this.taskFileMtimes.delete(taskId) } } - - if (changed) { - this.scheduleIndexWrite() - } }) } @@ -355,23 +448,43 @@ export class TaskHistoryStore { * - Parent `delegated` with no `awaitingChildId` → parent → `active` (invalid state) * - Parent `delegated`, child not found → parent → `active` (orphaned delegation) * - Parent `delegated`, child `completed` → parent → `active` (interrupted handoff) + * - Parent `delegated`, child `active` → child → `interrupted`, parent → `active` * - * A parent awaiting an `active`, `interrupted`, or `delegated` child is left as-is — the child is resumable. + * A parent awaiting an `interrupted` or `delegated` child is left as-is — the child is + * resumable. An `active` child is treated as orphaned during startup recovery because + * no live task session exists to own it. */ - private async reconcileDelegationState(): Promise { - return this.withLock(async () => { - let repairsInThisPass: number - do { - repairsInThisPass = 0 - // Rebuild the lookup map each pass so repairs from the previous pass - // are visible when evaluating chained delegations. - const byId = new Map(Array.from(this.cache.values()).map((i) => [i.id, i])) - - for (const [, item] of byId) { - if (item.status !== "delegated") { - continue - } + private async reconcileDelegationState(persistedActiveIds: ReadonlySet): Promise { + return this.withLock(() => this.reconcileDelegationStateCore(persistedActiveIds)) + } + /** + * Reconcile delegation state while the store lock is already held. + * + * Callers that do not hold the lock must use `reconcileDelegationState()`. + * Migration uses this core method so its cache/file updates and the + * follow-up repair remain one serialized operation without re-entering the + * non-reentrant lock. + */ + private async reconcileDelegationStateCore(persistedActiveIds: ReadonlySet): Promise { + // Only statuses loaded from persistence represent sessions that could have + // been orphaned by a crash. A delegated parent repaired to active earlier in + // this pass remains resumable and must not be mistaken for a second orphaned + // child in a delegation chain. The snapshot is intentionally captured before + // repair-intent replay and remains unchanged for the entire reconciliation. + let repairsInThisPass: number + do { + repairsInThisPass = 0 + // Rebuild the lookup map each pass so repairs from the previous pass + // are visible when evaluating chained delegations. + const byId = new Map(Array.from(this.cache.values()).map((i) => [i.id, i])) + + for (const [, item] of byId) { + if (item.status !== "delegated") { + continue + } + + try { if (!item.awaitingChildId) { await this.upsertCore( { ...item, status: "active", awaitingChildId: undefined, delegatedToId: undefined }, @@ -400,6 +513,16 @@ export class TaskHistoryStore { `[TaskHistoryStore] Reconciled orphaned delegation: task ${item.id} → active (child ${item.awaitingChildId} not found)`, ) repairsInThisPass++ + } else if ((child.status ?? "active") === "active" && persistedActiveIds.has(child.id)) { + // An active child persisted across startup cannot have a live task session + // behind it. Mark it interrupted before releasing the parent's delegation + // link so the normal resume/re-delegate flow can take over. This is an + // administrative recovery, not a runtime delegation transition. + await this.repairActiveDelegation(item, child) + console.warn( + `[TaskHistoryStore] Reconciled orphaned active child: child ${child.id} → interrupted, task ${item.id} → active`, + ) + repairsInThisPass++ } else if (child.status === "completed") { await this.upsertCore( { @@ -418,12 +541,262 @@ export class TaskHistoryStore { ) repairsInThisPass++ } - // child.status === "active", "interrupted", or "delegated" → leave as-is this pass + } catch (error) { + console.error(`[TaskHistoryStore] Failed to reconcile delegation for task ${item.id}:`, error) } - } while (repairsInThisPass > 0) + // child.status === "interrupted" or "delegated" → leave as-is this pass + } + } while (repairsInThisPass > 0) + } + + private getPersistedActiveIds(): ReadonlySet { + return new Set( + Array.from(this.cache.values()) + .filter((item) => (item.status ?? "active") === "active") + .map((item) => item.id), + ) + } + + /** + * Replay the durable active-child repair intent, if one was left by a crash. + * The expected fields are guards: an intent may update only the missing side + * when the other side is already at its target, or when both records still + * describe the original delegated handoff. + * + * This method acquires the store's non-reentrant promise-chain lock. It must be + * called outside an existing `withLock` callback; locked callers must use the + * corresponding core methods directly instead of awaiting this method. + */ + private async replayDelegationRepairIntent(): Promise { + return this.withLock(async () => { + const intent = await this.readDelegationRepairIntent() + if (!intent) { + return + } + + const child = this.cache.get(intent.childTaskId) + const parent = this.cache.get(intent.parentTaskId) + if (!child || !parent) { + await this.quarantineDelegationRepairIntent( + intent, + `missing ${!child ? "child" : "parent"} task record`, + ) + return + } + + const childAtTarget = child.status === intent.target.childStatus + const parentMatchesTargetState = + parent.status === intent.target.parentStatus && + parent.awaitingChildId === undefined && + parent.delegatedToId === undefined + const childMatchesExpected = this.matchesDelegationRepairChildPreconditions(intent, child) + const parentMatchesExpected = this.matchesDelegationRepairParentPreconditions(intent, parent) + + if ((!childAtTarget && !childMatchesExpected) || (!parentMatchesTargetState && !parentMatchesExpected)) { + await this.quarantineDelegationRepairIntent(intent, "task state no longer matches its guards") + return + } + + const repairedChild = childAtTarget ? child : { ...child, status: intent.target.childStatus } + const repairedParent = parentMatchesTargetState + ? parent + : { + ...parent, + status: intent.target.parentStatus, + awaitingChildId: undefined, + delegatedToId: undefined, + } + + if (!childAtTarget) await this.writeTaskFile(repairedChild) + if (!parentMatchesTargetState) await this.writeTaskFile(repairedParent) + + this.cache.set(repairedChild.id, repairedChild) + this.cache.set(repairedParent.id, repairedParent) + + // The journal is retained until the write-through callback succeeds. + if (this.onWrite) { + await this.onWrite(this.getAll()) + } + await this.removeDelegationRepairIntent() }) } + /** + * Start and complete a guarded active-child repair while already holding the + * store lock. The intent is durable before either task file is touched. + */ + private async repairActiveDelegation(parent: HistoryItem, child: HistoryItem): Promise { + const intent: DelegationRepairIntent = { + version: 1, + operationId: crypto.randomUUID(), + parentTaskId: parent.id, + childTaskId: child.id, + expected: { + parent: { + status: "delegated", + awaitingChildId: child.id, + delegatedToId: parent.delegatedToId, + }, + child: { + status: "active", + parentTaskId: child.parentTaskId, + rootTaskId: child.rootTaskId, + }, + }, + target: { childStatus: "interrupted", parentStatus: "active" }, + } + + await this.writeDelegationRepairIntent(intent) + await this.applyDelegationRepairIntent(intent, child, parent) + } + + private async applyDelegationRepairIntent( + intent: DelegationRepairIntent, + child: HistoryItem, + parent: HistoryItem, + ): Promise { + const repairedChild = { ...child, status: intent.target.childStatus } + const repairedParent = { + ...parent, + status: intent.target.parentStatus, + awaitingChildId: undefined, + delegatedToId: undefined, + } + + await this.writeTaskFile(repairedChild) + await this.writeTaskFile(repairedParent) + + this.cache.set(repairedChild.id, repairedChild) + this.cache.set(repairedParent.id, repairedParent) + + if (this.onWrite) { + await this.onWrite(this.getAll()) + } + await this.removeDelegationRepairIntent() + } + + private matchesDelegationRepairParentPreconditions(intent: DelegationRepairIntent, parent: HistoryItem): boolean { + return ( + parent.status === intent.expected.parent.status && + parent.awaitingChildId === intent.expected.parent.awaitingChildId && + parent.delegatedToId === intent.expected.parent.delegatedToId + ) + } + + private matchesDelegationRepairChildPreconditions(intent: DelegationRepairIntent, child: HistoryItem): boolean { + return ( + (child.status ?? "active") === intent.expected.child.status && + child.parentTaskId === intent.expected.child.parentTaskId && + child.rootTaskId === intent.expected.child.rootTaskId + ) + } + + private async readDelegationRepairIntent(): Promise { + const intentPath = await this.getDelegationRepairIntentPath() + let parsed: unknown + try { + parsed = JSON.parse(await fs.readFile(intentPath, "utf8")) as unknown + } catch (error) { + if (this.isFileNotFoundError(error)) { + return null + } + await this.quarantineDelegationRepairIntent(null, "malformed JSON") + return null + } + + if (!this.isDelegationRepairIntent(parsed)) { + await this.quarantineDelegationRepairIntent(null, "malformed intent") + return null + } + return parsed + } + + private isDelegationRepairIntent(value: unknown): value is DelegationRepairIntent { + if (!value || typeof value !== "object") { + return false + } + const candidate = value as Record + const expected = candidate.expected + const expectedRecord = expected && typeof expected === "object" ? (expected as Record) : null + const expectedParent = + expectedRecord?.parent && typeof expectedRecord.parent === "object" + ? (expectedRecord.parent as Record) + : null + const expectedChild = + expectedRecord?.child && typeof expectedRecord.child === "object" + ? (expectedRecord.child as Record) + : null + const target = candidate.target + const targetRecord = target && typeof target === "object" ? (target as Record) : null + return ( + candidate.version === 1 && + typeof candidate.operationId === "string" && + this.isSafeTaskId(candidate.parentTaskId) && + this.isSafeTaskId(candidate.childTaskId) && + candidate.parentTaskId !== candidate.childTaskId && + !!expectedParent && + expectedParent.status === "delegated" && + typeof expectedParent.awaitingChildId === "string" && + expectedParent.awaitingChildId === candidate.childTaskId && + (expectedParent.delegatedToId === undefined || typeof expectedParent.delegatedToId === "string") && + !!expectedChild && + expectedChild.status === "active" && + (expectedChild.parentTaskId === undefined || typeof expectedChild.parentTaskId === "string") && + (expectedChild.rootTaskId === undefined || typeof expectedChild.rootTaskId === "string") && + !!targetRecord && + targetRecord.childStatus === "interrupted" && + targetRecord.parentStatus === "active" + ) + } + + private async writeDelegationRepairIntent(intent: DelegationRepairIntent): Promise { + await safeWriteJson(await this.getDelegationRepairIntentPath(), intent) + } + + private async removeDelegationRepairIntent(): Promise { + try { + await fs.unlink(await this.getDelegationRepairIntentPath()) + } catch (error) { + console.warn("[TaskHistoryStore] Failed to remove completed delegation repair intent:", error) + } + } + + private async quarantineDelegationRepairIntent( + intent: DelegationRepairIntent | null, + reason: string, + ): Promise { + const intentPath = await this.getDelegationRepairIntentPath() + const quarantinePath = `${intentPath}.quarantine-${Date.now()}-${Math.random().toString(36).slice(2)}` + try { + await fs.rename(intentPath, quarantinePath) + } catch (error) { + console.warn("[TaskHistoryStore] Failed to quarantine delegation repair intent:", error) + } + console.warn( + `[TaskHistoryStore] Ignored ${intent ? `stale delegation repair intent ${intent.operationId}` : "malformed delegation repair intent"}: ${reason}`, + ) + } + + private isSafeTaskId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value !== "." && + value !== ".." && + !value.includes("/") && + !value.includes("\\") + ) + } + + private isFileNotFoundError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT" + } + + private async getDelegationRepairIntentPath(): Promise { + const tasksDir = await this.getTasksDir() + return path.join(tasksDir, GlobalFileNames.delegationRepairIntent) + } + // ────────────────────────────── Cache invalidation ────────────────────────────── /** @@ -438,6 +811,7 @@ export class TaskHistoryStore { } else { this.cache.delete(taskId) } + this.taskFileMtimes.delete(taskId) } catch { this.cache.delete(taskId) } @@ -466,123 +840,82 @@ export class TaskHistoryStore { return } - for (const item of taskHistoryEntries) { - if (!item.id) { - continue - } - - // Check if task directory exists on disk + await this.withLock(async () => { const tasksDir = await this.getTasksDir() - const taskDir = path.join(tasksDir, item.id) - - try { - await fs.access(taskDir) - } catch { - // Task directory doesn't exist; skip this entry as it's orphaned in globalState - continue - } - - // Write history_item.json if it doesn't exist yet - const filePath = path.join(taskDir, GlobalFileNames.historyItem) - try { - await fs.access(filePath) - // File already exists, skip (don't overwrite existing per-task files) - } catch { - // File doesn't exist, write it - await safeWriteJson(filePath, item) - this.cache.set(item.id, item) - } - } - // Write the index - await this.writeIndex() - - // Repair any delegation inconsistencies introduced by the migrated entries. - // reconcileDelegationState() is idempotent so running it again is safe. - await this.reconcileDelegationState() - } + for (const item of taskHistoryEntries) { + if (!item.id) { + continue + } - // ────────────────────────────── Private: Index management ────────────────────────────── + // Check if task directory exists on disk + const taskDir = path.join(tasksDir, item.id) - /** - * Load the `_index.json` file into the in-memory cache. - */ - private async loadIndex(): Promise { - const indexPath = await this.getIndexPath() - - try { - const raw = await fs.readFile(indexPath, "utf8") - const index: HistoryIndex = JSON.parse(raw) + try { + await fs.access(taskDir) + } catch { + // Task directory doesn't exist; skip this entry as it's orphaned in globalState + continue + } - if (index.version === 1 && Array.isArray(index.entries)) { - for (const entry of index.entries) { - if (entry.id) { - this.cache.set(entry.id, entry) - } + // Write history_item.json if it doesn't exist yet + const filePath = path.join(taskDir, GlobalFileNames.historyItem) + try { + await fs.access(filePath) + // File already exists, skip (don't overwrite existing per-task files) + } catch { + // File doesn't exist, write it + await safeWriteJson(filePath, item) + this.cache.set(item.id, item) } } - } catch { - // Index doesn't exist or is corrupted; cache stays empty. - // Reconciliation will rebuild it from per-task files. - } - } - - /** - * Write the full index to disk. - */ - private async writeIndex(): Promise { - const indexPath = await this.getIndexPath() - const index: HistoryIndex = { - version: 1, - updatedAt: Date.now(), - entries: this.getAll(), - } - await safeWriteJson(indexPath, index) + // Repair any delegation inconsistencies introduced by the migrated entries. + // Run the lock-free core because migration already holds the store lock. + await this.reconcileDelegationStateCore(this.getPersistedActiveIds()) + }) } - /** - * Schedule a debounced index write. - */ - private scheduleIndexWrite(): void { - if (this.disposed) { - return - } - - if (this.indexWriteTimer) { - clearTimeout(this.indexWriteTimer) - } - - this.indexWriteTimer = setTimeout(async () => { - this.indexWriteTimer = null - try { - await this.writeIndex() - } catch (err) { - console.error("[TaskHistoryStore] Failed to write index:", err) - } - }, TaskHistoryStore.INDEX_WRITE_DEBOUNCE_MS) - } + // ────────────────────────────── Private: Per-task file I/O ────────────────────────────── /** - * Force an immediate index write (called on dispose/shutdown). + * Return only the fields in `incoming` that differ from `cached`. */ - async flushIndex(): Promise { - if (this.indexWriteTimer) { - clearTimeout(this.indexWriteTimer) - this.indexWriteTimer = null - } - - await this.writeIndex() + private computeDelta(cached: HistoryItem, incoming: Partial): Partial { + return Object.fromEntries( + Object.entries(incoming).filter(([k, v]) => !deepEqual(v, (cached as Record)[k])), + ) as Partial } - // ────────────────────────────── Private: Per-task file I/O ────────────────────────────── + private buildDelta(id: string, cached: HistoryItem, incoming: Partial): Partial { + return { id, ...this.computeDelta(cached, incoming) } + } /** * Write a HistoryItem to its per-task `history_item.json` file. + * + * When `delta` is provided, the merge callback applies only the + * delta to the current disk state, so fields written by another + * process are preserved. Without a delta the full item is written + * as-is (used by administrative repair paths that are authoritative). */ - private async writeTaskFile(item: HistoryItem): Promise { + private async writeTaskFile(item: HistoryItem, delta?: Partial): Promise { const filePath = await this.getTaskFilePath(item.id) - await safeWriteJson(filePath, item) + if (delta) { + let written: HistoryItem = item + const mergeFn = mergeWithDisk(delta) + await safeWriteJson(filePath, item, { + merge: (existing, incoming) => { + const result = mergeFn(existing, incoming) + written = result as HistoryItem + return result + }, + }) + return written + } else { + await safeWriteJson(filePath, item) + return item + } } /** @@ -703,10 +1036,11 @@ export class TaskHistoryStore { } /** - * Atomically update two related HistoryItems within a single lock acquisition. - * Both updaters run synchronously (no I/O, no lock re-entry). Both writes are - * committed before the lock releases — no concurrent writer can observe an - * intermediate state. + * Update two related HistoryItems within a single in-process lock acquisition. + * Both updaters run synchronously (no I/O, no lock re-entry). Both writes + * complete before the lock releases, so no in-process reader can observe an + * intermediate state. Cross-process atomicity is NOT guaranteed — each + * writeTaskFile call acquires and releases its own advisory file lock. * * @throws If either task ID is not present in the cache. */ @@ -753,16 +1087,21 @@ export class TaskHistoryStore { const mergedFirst = { ...first, ...updatedFirst } const mergedSecond = { ...second, ...updatedSecond } - // Write both files before touching the cache so readers never observe a - // half-updated in-memory state between the two await points. - await this.writeTaskFile(mergedFirst) - await this.writeTaskFile(mergedSecond) + const writtenFirst = await this.writeTaskFile(mergedFirst, this.buildDelta(firstId, first, updatedFirst)) + let writtenSecond: HistoryItem + try { + writtenSecond = await this.writeTaskFile(mergedSecond, this.buildDelta(secondId, second, updatedSecond)) + } catch (error) { + // First record is committed on disk. Update cache so it + // reflects disk state before propagating the error. + this.cache.set(firstId, writtenFirst) + throw error + } - // Both disk writes succeeded — now update the cache atomically. - this.cache.set(firstId, mergedFirst) - this.cache.set(secondId, mergedSecond) + // Both disk writes succeeded — now update the cache. + this.cache.set(firstId, writtenFirst) + this.cache.set(secondId, writtenSecond) - this.scheduleIndexWrite() const all = this.getAll() if (this.onWrite) { await this.onWrite(all) @@ -803,12 +1142,4 @@ export class TaskHistoryStore { const tasksDir = await this.getTasksDir() return path.join(tasksDir, taskId, GlobalFileNames.historyItem) } - - /** - * Get the path to the `_index.json` file. - */ - private async getIndexPath(): Promise { - const tasksDir = await this.getTasksDir() - return path.join(tasksDir, GlobalFileNames.historyIndex) - } } diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts index e5166c478c..cef9874e5f 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts @@ -6,7 +6,7 @@ import * as os from "os" import type { HistoryItem } from "@roo-code/types" -import { TaskHistoryStore } from "../TaskHistoryStore" +import { TaskHistoryStore, DeltaRejectedError } from "../TaskHistoryStore" import { GlobalFileNames } from "../../../shared/globalFileNames" vi.mock("../../../utils/storage", () => ({ @@ -15,12 +15,30 @@ vi.mock("../../../utils/storage", () => ({ }), })) -// Mock safeWriteJson to use plain fs writes in tests (avoids proper-lockfile issues) +// Mock safeWriteJson to use plain fs writes but honor the merge callback. vi.mock("../../../utils/safeWriteJson", () => ({ - safeWriteJson: vi.fn().mockImplementation(async (filePath: string, data: any) => { - await fs.mkdir(path.dirname(filePath), { recursive: true }) - await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf8") - }), + safeWriteJson: vi + .fn() + .mockImplementation( + async ( + filePath: string, + data: unknown, + options?: { merge?: (existing: unknown, incoming: unknown) => unknown }, + ) => { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + if (options?.merge) { + let existing: unknown = null + try { + const raw = await fs.readFile(filePath, "utf8") + existing = JSON.parse(raw) + } catch { + // File does not exist or is corrupt + } + data = options.merge(existing, data) + } + await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf8") + }, + ), })) function makeHistoryItem(overrides: Partial = {}): HistoryItem { @@ -124,6 +142,27 @@ describe("TaskHistoryStore cross-instance safety", () => { expect(storeB.get("shared-task")).toBeUndefined() }) + it("delete by instance A is detected even when the task directory remains", async () => { + await storeA.initialize() + await storeB.initialize() + + const item = makeHistoryItem({ id: "file-only-delete" }) + await storeA.upsert(item) + await storeB.reconcile() + + expect(storeB.get("file-only-delete")).toBeDefined() + + // delete() unlinks history_item.json but leaves the task directory. + await storeA.delete("file-only-delete") + + // Directory still exists (other files like ui_messages.json may remain). + const taskDir = path.join(tmpDir, "tasks", "file-only-delete") + await expect(fs.access(taskDir)).resolves.toBeUndefined() + + await storeB.reconcile() + expect(storeB.get("file-only-delete")).toBeUndefined() + }) + it("per-task file updates by one instance are visible to another after invalidation", async () => { await storeA.initialize() await storeB.initialize() @@ -164,4 +203,107 @@ describe("TaskHistoryStore cross-instance safety", () => { expect(storeA.getAll().length).toBe(10) expect(storeB.getAll().length).toBe(10) }) + + /** + * Host B completes a task on disk while host A's cache still has it + * active. Host A's next save updates only totalCost (a full-object + * upsert — the realistic production shape). The diff-delta merge + * preserves B's status because status did not change in A's cache. + */ + it("per-task diff-delta preserves a peer's status change on full-object upsert", async () => { + await storeA.initialize() + + // Base item with an explicit status — mirrors real production items. + const base = makeHistoryItem({ id: "shared-task", status: "active", totalCost: 0.01, ts: 1000 }) + await storeA.upsert(base) + + // Host B completes the task on disk; A's cache still has "active". + const filePath = path.join(tmpDir, "tasks", "shared-task", GlobalFileNames.historyItem) + const onDisk = JSON.parse(await fs.readFile(filePath, "utf8")) + onDisk.status = "completed" + onDisk.completionResultSummary = "done by host B" + await fs.writeFile(filePath, JSON.stringify(onDisk), "utf8") + + // Host A does a full-object upsert (the realistic path — spread the + // cached item and change one field). The cached item has status: "active". + await storeA.upsert({ ...storeA.get("shared-task")!, totalCost: 9.99 }) + + const final = JSON.parse(await fs.readFile(filePath, "utf8")) as HistoryItem + expect(final.totalCost).toBe(9.99) + // Status is preserved from disk because A's delta does not include + // status — it was unchanged relative to A's cache. + expect(final.status).toBe("completed") + expect(final.completionResultSummary).toBe("done by host B") + + // Cache reflects the caller's totalCost change and the peer's status. + expect(storeA.get("shared-task")!.totalCost).toBe(9.99) + expect(storeA.get("shared-task")!.status).toBe("completed") + expect(storeA.get("shared-task")!.completionResultSummary).toBe("done by host B") + }) + + /** + * Regression: a stale host whose cache says "active" tries to write + * status: "delegated" after a peer already wrote "completed" to disk. + * The merge must reject the entire delta (including companion fields) + * to prevent an internally-inconsistent record. + */ + it("merge rejects an invalid status transition against disk and throws DeltaRejectedError", async () => { + await storeA.initialize() + + const base = makeHistoryItem({ id: "guarded-task", status: "active", totalCost: 0.01, ts: 1000 }) + await storeA.upsert(base) + + // Peer writes terminal "completed" directly to disk. + const filePath = path.join(tmpDir, "tasks", "guarded-task", GlobalFileNames.historyItem) + const onDisk = JSON.parse(await fs.readFile(filePath, "utf8")) + onDisk.status = "completed" + onDisk.completionResultSummary = "done by peer" + await fs.writeFile(filePath, JSON.stringify(onDisk), "utf8") + + // Host A's cache still has "active". It tries to delegate (active → delegated + // passes the cache check, but completed → delegated is invalid on disk). + const staleItem = storeA.get("guarded-task")! + await expect( + storeA.upsert({ + ...staleItem, + status: "delegated", + awaitingChildId: "child-99", + delegatedToId: "child-99", + }), + ).rejects.toThrow(DeltaRejectedError) + + const final = JSON.parse(await fs.readFile(filePath, "utf8")) as HistoryItem + // Terminal status must survive — disk is untouched. + expect(final.status).toBe("completed") + expect(final.completionResultSummary).toBe("done by peer") + // Companion fields from the rejected delta must NOT be applied. + expect(final.awaitingChildId).toBeUndefined() + expect(final.delegatedToId).toBeUndefined() + + // Cache must reflect the disk state, not the stale delta. + expect(storeA.get("guarded-task")!.status).toBe("completed") + }) + + /** + * When both hosts change the same field, the last writer wins. + * This is expected — true conflict resolution requires application + * semantics that a generic merge cannot provide. + */ + it("same-field changes from both hosts are last-writer-wins", async () => { + await storeA.initialize() + await storeB.initialize() + + const base = makeHistoryItem({ id: "shared-task", status: "active", totalCost: 0.01, ts: 1000 }) + await storeA.upsert(base) + await storeB.reconcile() + + // Both hosts change totalCost. + await storeA.upsert({ ...storeA.get("shared-task")!, totalCost: 1.0 }) + await storeB.upsert({ ...storeB.get("shared-task")!, totalCost: 2.0 }) + + const filePath = path.join(tmpDir, "tasks", "shared-task", GlobalFileNames.historyItem) + const final = JSON.parse(await fs.readFile(filePath, "utf8")) as HistoryItem + // B wrote last, so B's value wins. + expect(final.totalCost).toBe(2.0) + }) }) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts index 2888c0f7b2..e37fd1a25e 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts @@ -6,18 +6,23 @@ import * as os from "os" import type { HistoryItem } from "@roo-code/types" +import { GlobalFileNames } from "../../../shared/globalFileNames" import { TaskHistoryStore, assertValidTransition } from "../TaskHistoryStore" vi.mock("../../../utils/storage", () => ({ getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => defaultPath), })) -vi.mock("../../../utils/safeWriteJson", () => ({ - safeWriteJson: vi.fn().mockImplementation(async (filePath: string, data: any) => { - await fs.mkdir(path.dirname(filePath), { recursive: true }) - await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf8") - }), -})) +const writeJson = async (filePath: string, data: unknown): Promise => { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf8") +} + +const safeWriteJsonMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../../utils/safeWriteJson", () => ({ safeWriteJson: safeWriteJsonMock })) + +safeWriteJsonMock.mockImplementation(writeJson) function makeItem(overrides: Partial = {}): HistoryItem { return { @@ -32,6 +37,28 @@ function makeItem(overrides: Partial = {}): HistoryItem { } } +function makeRepairIntent(parent: HistoryItem, child: HistoryItem): object { + return { + version: 1, + operationId: "delegation-repair-test", + parentTaskId: parent.id, + childTaskId: child.id, + expected: { + parent: { + status: "delegated", + awaitingChildId: child.id, + delegatedToId: parent.delegatedToId, + }, + child: { + status: "active", + parentTaskId: child.parentTaskId, + rootTaskId: child.rootTaskId, + }, + }, + target: { childStatus: "interrupted", parentStatus: "active" }, + } +} + // ───────────────────────────────────────────────────────────────────────────── // assertValidTransition — pure function tests // ───────────────────────────────────────────────────────────────────────────── @@ -117,6 +144,12 @@ describe("assertValidTransition", () => { describe("TaskHistoryStore reconcileDelegationState", () => { let tmpDir: string let store: TaskHistoryStore + const disposables = new Set() + + function registerStore(nextStore: TaskHistoryStore): TaskHistoryStore { + disposables.add(nextStore) + return nextStore + } async function seedItems(items: HistoryItem[]): Promise { const tasksDir = path.join(tmpDir, "tasks") @@ -130,11 +163,13 @@ describe("TaskHistoryStore reconcileDelegationState", () => { beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "reconcile-test-")) - store = new TaskHistoryStore(tmpDir) + store = registerStore(new TaskHistoryStore(tmpDir)) }) afterEach(async () => { - store.dispose() + safeWriteJsonMock.mockImplementation(writeJson) + for (const disposable of disposables) disposable.dispose() + disposables.clear() await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) }) @@ -185,16 +220,386 @@ describe("TaskHistoryStore reconcileDelegationState", () => { expect(repaired?.completionResultSummary).toBe("Task completed (recovered after interruption)") }) - it("leaves delegated parent alone when child is still active", async () => { - const child = makeItem({ id: "child-4", status: "active" }) - const parent = makeItem({ id: "parent-4", status: "delegated", awaitingChildId: "child-4" }) + it("repairs a delegated parent with an active orphaned child", async () => { + const child = makeItem({ + id: "child-4", + status: "active", + parentTaskId: "parent-4", + rootTaskId: "parent-4", + childIds: ["grandchild-4"], + }) + const parent = makeItem({ + id: "parent-4", + status: "delegated", + awaitingChildId: "child-4", + delegatedToId: "child-4", + childIds: ["child-4"], + }) + await seedItems([parent, child]) + + await store.initialize() + + const repairedParent = store.get("parent-4") + const repairedChild = store.get("child-4") + expect(repairedChild).toMatchObject({ + id: "child-4", + status: "interrupted", + parentTaskId: "parent-4", + rootTaskId: "parent-4", + childIds: ["grandchild-4"], + }) + expect(repairedParent).toMatchObject({ + id: "parent-4", + status: "active", + childIds: ["child-4"], + }) + expect(repairedParent?.awaitingChildId).toBeUndefined() + expect(repairedParent?.delegatedToId).toBeUndefined() + + const tasksDir = path.join(tmpDir, "tasks") + const persistedChild = JSON.parse( + await fs.readFile(path.join(tasksDir, "child-4", "history_item.json"), "utf8"), + ) as HistoryItem + const persistedParent = JSON.parse( + await fs.readFile(path.join(tasksDir, "parent-4", "history_item.json"), "utf8"), + ) as HistoryItem + expect(persistedChild).toMatchObject({ + id: "child-4", + status: "interrupted", + parentTaskId: "parent-4", + rootTaskId: "parent-4", + childIds: ["grandchild-4"], + }) + expect(persistedParent).toMatchObject({ id: "parent-4", status: "active" }) + expect(persistedParent.awaitingChildId).toBeUndefined() + expect(persistedParent.delegatedToId).toBeUndefined() + }) + + it("repairs a delegated child with an omitted status as implicit active", async () => { + const child = makeItem({ + id: "child-implicit-active", + parentTaskId: "parent-implicit-active", + rootTaskId: "parent-implicit-active", + }) + const parent = makeItem({ + id: "parent-implicit-active", + status: "delegated", + awaitingChildId: child.id, + delegatedToId: child.id, + }) + await seedItems([parent, child]) + + await store.initialize() + + expect(store.get(child.id)).toMatchObject({ id: child.id, status: "interrupted" }) + expect(store.get(parent.id)).toMatchObject({ id: parent.id, status: "active" }) + expect(store.get(parent.id)?.awaitingChildId).toBeUndefined() + expect(store.get(parent.id)?.delegatedToId).toBeUndefined() + + const persistedChild = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", child.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + expect(persistedChild.status).toBe("interrupted") + }) + + it("replays an intent after a child-only write and removes it after completion", async () => { + const child = makeItem({ id: "child-replay", status: "active", parentTaskId: "parent-replay" }) + const parent = makeItem({ + id: "parent-replay", + status: "delegated", + awaitingChildId: child.id, + delegatedToId: child.id, + }) await seedItems([parent, child]) + const intentPath = path.join(tmpDir, "tasks", GlobalFileNames.delegationRepairIntent) + await fs.writeFile(intentPath, JSON.stringify(makeRepairIntent(parent, child))) + await fs.writeFile( + path.join(tmpDir, "tasks", child.id, GlobalFileNames.historyItem), + JSON.stringify({ ...child, status: "interrupted" }), + ) await store.initialize() - const unchanged = store.get("parent-4") - expect(unchanged?.status).toBe("delegated") - expect(unchanged?.awaitingChildId).toBe("child-4") + expect(store.get(child.id)?.status).toBe("interrupted") + expect(store.get(parent.id)?.status).toBe("active") + const persistedChild = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", child.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + const persistedParent = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", parent.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + expect(persistedChild).toMatchObject({ id: child.id, status: "interrupted" }) + expect(persistedParent).toMatchObject({ id: parent.id, status: "active" }) + expect(persistedParent.awaitingChildId).toBeUndefined() + expect(persistedParent.delegatedToId).toBeUndefined() + await expect(fs.access(intentPath)).rejects.toThrow() + }) + + it("replays an intent after a failure before the child write", async () => { + const child = makeItem({ + id: "child-fault-before-child", + status: "active", + parentTaskId: "parent-fault-before-child", + }) + const parent = makeItem({ + id: "parent-fault-before-child", + status: "delegated", + awaitingChildId: child.id, + delegatedToId: child.id, + }) + await seedItems([parent, child]) + safeWriteJsonMock.mockImplementation(async (filePath, data) => { + if (filePath.includes(child.id) && filePath.endsWith(GlobalFileNames.historyItem)) + throw new Error("fault before child write") + await writeJson(filePath, data) + }) + await expect(store.initialize()).resolves.toBeUndefined() + store.dispose() + safeWriteJsonMock.mockImplementation(writeJson) + const replayedStore = registerStore(new TaskHistoryStore(tmpDir)) + await replayedStore.initialize() + expect(replayedStore.get(child.id)?.status).toBe("interrupted") + expect(replayedStore.get(parent.id)?.status).toBe("active") + const persistedChild = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", child.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + const persistedParent = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", parent.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + expect(persistedChild).toMatchObject({ id: child.id, status: "interrupted" }) + expect(persistedParent).toMatchObject({ id: parent.id, status: "active" }) + expect(persistedParent.awaitingChildId).toBeUndefined() + expect(persistedParent.delegatedToId).toBeUndefined() + }) + + it("replays an intent after a failure before the parent write", async () => { + const child = makeItem({ + id: "child-fault-before-parent", + status: "active", + parentTaskId: "parent-fault-before-parent", + }) + const parent = makeItem({ + id: "parent-fault-before-parent", + status: "delegated", + awaitingChildId: child.id, + delegatedToId: child.id, + }) + await seedItems([parent, child]) + safeWriteJsonMock.mockImplementation(async (filePath, data) => { + if (filePath.includes(parent.id) && filePath.endsWith(GlobalFileNames.historyItem)) + throw new Error("fault before parent write") + await writeJson(filePath, data) + }) + await expect(store.initialize()).resolves.toBeUndefined() + store.dispose() + safeWriteJsonMock.mockImplementation(writeJson) + const replayedStore = registerStore(new TaskHistoryStore(tmpDir)) + await replayedStore.initialize() + expect(replayedStore.get(child.id)?.status).toBe("interrupted") + expect(replayedStore.get(parent.id)?.status).toBe("active") + const persistedChild = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", child.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + const persistedParent = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", parent.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + expect(persistedChild).toMatchObject({ id: child.id, status: "interrupted" }) + expect(persistedParent).toMatchObject({ id: parent.id, status: "active" }) + expect(persistedParent.awaitingChildId).toBeUndefined() + expect(persistedParent.delegatedToId).toBeUndefined() + }) + + it("retains an intent when the callback fails after both writes", async () => { + const child = makeItem({ id: "child-fault-cleanup", status: "active", parentTaskId: "parent-fault-cleanup" }) + const parent = makeItem({ + id: "parent-fault-cleanup", + status: "delegated", + awaitingChildId: child.id, + delegatedToId: child.id, + }) + await seedItems([parent, child]) + store.dispose() + store = registerStore( + new TaskHistoryStore(tmpDir, { onWrite: vi.fn().mockRejectedValue(new Error("fault before cleanup")) }), + ) + await expect(store.initialize()).resolves.toBeUndefined() + const intentPath = path.join(tmpDir, "tasks", GlobalFileNames.delegationRepairIntent) + expect(await fs.readFile(intentPath, "utf8")).toContain(child.id) + store.dispose() + safeWriteJsonMock.mockImplementation(writeJson) + const replayedStore = registerStore(new TaskHistoryStore(tmpDir)) + await replayedStore.initialize() + expect(replayedStore.get(child.id)?.status).toBe("interrupted") + expect(replayedStore.get(parent.id)?.status).toBe("active") + const persistedChild = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", child.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + const persistedParent = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", parent.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + expect(persistedChild).toMatchObject({ id: child.id, status: "interrupted" }) + expect(persistedParent).toMatchObject({ id: parent.id, status: "active" }) + expect(persistedParent.awaitingChildId).toBeUndefined() + expect(persistedParent.delegatedToId).toBeUndefined() + await expect(fs.access(intentPath)).rejects.toThrow() + }) + + it("replays a both-at-target intent without writing task files", async () => { + const child = makeItem({ id: "child-at-target", status: "interrupted", parentTaskId: "parent-at-target" }) + const parent = makeItem({ id: "parent-at-target", status: "active" }) + await seedItems([parent, child]) + const intentPath = path.join(tmpDir, "tasks", GlobalFileNames.delegationRepairIntent) + await fs.writeFile( + intentPath, + JSON.stringify(makeRepairIntent({ ...parent, status: "delegated", awaitingChildId: child.id }, child)), + ) + + const writeCalls: string[] = [] + safeWriteJsonMock.mockImplementation(async (filePath, data) => { + writeCalls.push(filePath) + await writeJson(filePath, data) + }) + await store.initialize() + + expect(writeCalls.filter((filePath) => filePath.endsWith(GlobalFileNames.historyItem))).toEqual([]) + await expect(fs.access(intentPath)).rejects.toThrow() + }) + + it("removes the repair-intent file after successful replay", async () => { + const child = makeItem({ id: "child-deferred-index", status: "active", parentTaskId: "parent-deferred-index" }) + const parent = makeItem({ + id: "parent-deferred-index", + status: "delegated", + awaitingChildId: child.id, + delegatedToId: child.id, + }) + await seedItems([parent, child]) + const intentPath = path.join(tmpDir, "tasks", GlobalFileNames.delegationRepairIntent) + await fs.writeFile(intentPath, JSON.stringify(makeRepairIntent(parent, child))) + await store.reconcile({ forceRefresh: true }) + + const storeInternals = store as unknown as { + replayDelegationRepairIntent: () => Promise + } + + await storeInternals.replayDelegationRepairIntent() + + await expect(fs.access(intentPath)).rejects.toThrow() + }) + + it("quarantines malformed and stale intents without blocking unrelated startup", async () => { + const unrelated = makeItem({ id: "unrelated-startup", status: "active" }) + await seedItems([unrelated]) + const tasksDir = path.join(tmpDir, "tasks") + const intentPath = path.join(tasksDir, GlobalFileNames.delegationRepairIntent) + await fs.writeFile(intentPath, JSON.stringify({ malformed: true })) + + await store.initialize() + + expect(store.get(unrelated.id)?.status).toBe("active") + expect( + (await fs.readdir(tasksDir)).some((name) => + name.startsWith(`${GlobalFileNames.delegationRepairIntent}.quarantine-`), + ), + ).toBe(true) + await expect(fs.access(intentPath)).rejects.toThrow() + }) + + it("quarantines an intent with a missing task record without changing unrelated startup", async () => { + const unrelated = makeItem({ id: "unrelated-missing-intent", status: "active" }) + const missingChild = makeItem({ id: "missing-intent-child", status: "active" }) + const parent = makeItem({ id: "missing-intent-parent", status: "delegated", awaitingChildId: missingChild.id }) + await seedItems([unrelated]) + const tasksDir = path.join(tmpDir, "tasks") + const intentPath = path.join(tasksDir, GlobalFileNames.delegationRepairIntent) + await fs.writeFile(intentPath, JSON.stringify(makeRepairIntent(parent, missingChild))) + + await store.initialize() + + expect(store.get(unrelated.id)?.status).toBe("active") + expect(store.get(parent.id)).toBeUndefined() + expect( + (await fs.readdir(tasksDir)).some((name) => + name.startsWith(`${GlobalFileNames.delegationRepairIntent}.quarantine-`), + ), + ).toBe(true) + await expect(fs.access(intentPath)).rejects.toThrow() + }) + + it("quarantines an intent when the parent no longer matches its repair guard", async () => { + const child = makeItem({ + id: "mismatched-intent-child", + status: "interrupted", + parentTaskId: "mismatched-intent-parent", + }) + const parent = makeItem({ + id: "mismatched-intent-parent", + status: "completed", + awaitingChildId: child.id, + delegatedToId: child.id, + }) + await seedItems([parent, child]) + const tasksDir = path.join(tmpDir, "tasks") + const intentPath = path.join(tasksDir, GlobalFileNames.delegationRepairIntent) + await fs.writeFile(intentPath, JSON.stringify(makeRepairIntent({ ...parent, status: "delegated" }, child))) + const childPath = path.join(tasksDir, child.id, GlobalFileNames.historyItem) + const parentPath = path.join(tasksDir, parent.id, GlobalFileNames.historyItem) + const beforeChild = await fs.readFile(childPath, "utf8") + const beforeParent = await fs.readFile(parentPath, "utf8") + + await store.initialize() + + expect(store.get(child.id)?.status).toBe("interrupted") + expect(store.get(parent.id)?.status).toBe("completed") + expect(await fs.readFile(childPath, "utf8")).toBe(beforeChild) + expect(await fs.readFile(parentPath, "utf8")).toBe(beforeParent) + expect( + (await fs.readdir(tasksDir)).some((name) => + name.startsWith(`${GlobalFileNames.delegationRepairIntent}.quarantine-`), + ), + ).toBe(true) + await expect(fs.access(intentPath)).rejects.toThrow() + }) + + it("quarantines an intent when the child no longer matches its repair guard", async () => { + const child = makeItem({ + id: "child-mismatched-intent", + status: "completed", + parentTaskId: "mismatched-child-parent", + }) + const parent = makeItem({ + id: "parent-mismatched-child-intent", + status: "active", + }) + await seedItems([parent, child]) + const tasksDir = path.join(tmpDir, "tasks") + const intentPath = path.join(tasksDir, GlobalFileNames.delegationRepairIntent) + await fs.writeFile( + intentPath, + JSON.stringify( + makeRepairIntent( + { ...parent, status: "delegated", awaitingChildId: child.id, delegatedToId: child.id }, + { ...child, status: "active" }, + ), + ), + ) + const childPath = path.join(tasksDir, child.id, GlobalFileNames.historyItem) + const parentPath = path.join(tasksDir, parent.id, GlobalFileNames.historyItem) + const beforeChild = await fs.readFile(childPath, "utf8") + const beforeParent = await fs.readFile(parentPath, "utf8") + + await store.initialize() + + expect(store.get(child.id)?.status).toBe("completed") + expect(store.get(parent.id)?.status).toBe("active") + expect(await fs.readFile(childPath, "utf8")).toBe(beforeChild) + expect(await fs.readFile(parentPath, "utf8")).toBe(beforeParent) + await expect(fs.access(intentPath)).rejects.toThrow() + expect( + (await fs.readdir(tasksDir)).some((name) => + name.startsWith(`${GlobalFileNames.delegationRepairIntent}.quarantine-`), + ), + ).toBe(true) }) it("repairs invalid delegation: delegated parent with no awaitingChildId → active (clears delegatedToId and awaitingChildId)", async () => { @@ -204,7 +609,7 @@ describe("TaskHistoryStore reconcileDelegationState", () => { status: "delegated", delegatedToId: "stale-child", awaitingChildId: "", - } as any) + }) await seedItems([parent]) await store.initialize() @@ -239,9 +644,9 @@ describe("TaskHistoryStore reconcileDelegationState", () => { expect(store.get("parent-b")?.status).toBe("active") }) - it("handles chained delegation (A→B→C): repairs B first, then A sees B as active and is left delegated", async () => { + it("repairs an orphaned link in a chained delegation without repairing its grandparent", async () => { // C doesn't exist (orphaned). B is delegated waiting for C → repaired to active. - // A is delegated waiting for B → left delegated (B is now active, resumable by user). + // A sees B as delegated in the persisted startup snapshot and remains delegated. const parentA = makeItem({ id: "parent-a-chain", status: "delegated", awaitingChildId: "parent-b-chain" }) const parentB = makeItem({ id: "parent-b-chain", @@ -254,9 +659,101 @@ describe("TaskHistoryStore reconcileDelegationState", () => { // B is repaired: its child (C) was missing expect(store.get("parent-b-chain")?.status).toBe("active") - // A stays delegated: its child (B) is now active, which is a valid state + // A stays delegated: B was repaired from delegated to active and remains + // resumable rather than being mistaken for an active orphan from disk. expect(store.get("parent-a-chain")?.status).toBe("delegated") expect(store.get("parent-a-chain")?.awaitingChildId).toBe("parent-b-chain") + expect(store.get("parent-b-chain")?.status).toBe("active") + expect(store.get("parent-b-chain")?.awaitingChildId).toBeUndefined() + }) + + it("does not repair a grandparent when replay repairs the middle node", async () => { + const grandparent = makeItem({ + id: "grandparent-replay-chain", + status: "delegated", + awaitingChildId: "parent-replay-chain", + delegatedToId: "parent-replay-chain", + }) + const parent = makeItem({ + id: "parent-replay-chain", + status: "delegated", + awaitingChildId: "child-replay-chain", + delegatedToId: "child-replay-chain", + parentTaskId: grandparent.id, + rootTaskId: grandparent.id, + }) + const child = makeItem({ + id: "child-replay-chain", + status: "active", + parentTaskId: parent.id, + rootTaskId: grandparent.id, + }) + await seedItems([grandparent, parent, child]) + const intentPath = path.join(tmpDir, "tasks", GlobalFileNames.delegationRepairIntent) + await fs.writeFile(intentPath, JSON.stringify(makeRepairIntent(parent, child))) + + await store.initialize() + + // Replay repairs B/C, but B was delegated at the persisted startup snapshot. + // A must remain delegated to the now-interrupted/resumable B. + expect(store.get(grandparent.id)).toMatchObject({ + id: grandparent.id, + status: "delegated", + awaitingChildId: parent.id, + delegatedToId: parent.id, + }) + expect(store.get(parent.id)).toMatchObject({ id: parent.id, status: "active" }) + expect(store.get(parent.id)?.awaitingChildId).toBeUndefined() + expect(store.get(parent.id)?.delegatedToId).toBeUndefined() + expect(store.get(child.id)).toMatchObject({ id: child.id, status: "interrupted" }) + + const persistedGrandparent = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", grandparent.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + const persistedParent = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", parent.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + const persistedChild = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", child.id, GlobalFileNames.historyItem), "utf8"), + ) as HistoryItem + expect(persistedGrandparent).toMatchObject({ + id: grandparent.id, + status: "delegated", + awaitingChildId: parent.id, + delegatedToId: parent.id, + }) + expect(persistedParent).toMatchObject({ id: parent.id, status: "active" }) + expect(persistedParent.awaitingChildId).toBeUndefined() + expect(persistedParent.delegatedToId).toBeUndefined() + expect(persistedChild).toMatchObject({ id: child.id, status: "interrupted" }) + await expect(fs.access(intentPath)).rejects.toThrow() + }) + + it("is idempotent when recovering an active child", async () => { + const child = makeItem({ id: "child-active-idempotent", status: "active" }) + const parent = makeItem({ + id: "parent-active-idempotent", + status: "delegated", + awaitingChildId: child.id, + delegatedToId: child.id, + }) + await seedItems([parent, child]) + + await store.initialize() + const afterFirstParent = { ...store.get(parent.id) } + const afterFirstChild = { ...store.get(child.id) } + + store.dispose() + const store2 = registerStore(new TaskHistoryStore(tmpDir)) + await store2.initialize() + const afterSecondParent = { ...store2.get(parent.id) } + const afterSecondChild = { ...store2.get(child.id) } + store2.dispose() + + expect(afterFirstParent).toMatchObject({ status: "active" }) + expect(afterSecondParent).toEqual(afterFirstParent) + expect(afterFirstChild).toMatchObject({ status: "interrupted" }) + expect(afterSecondChild).toEqual(afterFirstChild) }) it("is idempotent: running initialize twice produces the same result", async () => { @@ -268,7 +765,7 @@ describe("TaskHistoryStore reconcileDelegationState", () => { const afterFirst = { ...store.get("parent-6") } store.dispose() - const store2 = new TaskHistoryStore(tmpDir) + const store2 = registerStore(new TaskHistoryStore(tmpDir)) await store2.initialize() const afterSecond = { ...store2.get("parent-6") } store2.dispose() @@ -296,7 +793,7 @@ describe("TaskHistoryStore reconcileDelegationState", () => { it("invokes onWrite callback after startup repairs", async () => { const onWrite = vi.fn().mockResolvedValue(undefined) store.dispose() - store = new TaskHistoryStore(tmpDir, { onWrite }) + store = registerStore(new TaskHistoryStore(tmpDir, { onWrite })) const parent = makeItem({ id: "parent-onwrite", status: "delegated", awaitingChildId: "nonexistent-child" }) await seedItems([parent]) @@ -407,7 +904,7 @@ describe("TaskHistoryStore upsert transition guard", () => { it("rejects delegated → completed transition", async () => { // Must include a live active child so reconciliation doesn't repair the parent to active - const child = makeItem({ id: "child-guard-2", status: "active" }) + const child = makeItem({ id: "child-guard-2", status: "interrupted" }) const item = makeItem({ id: "task-guard-2", status: "delegated", awaitingChildId: "child-guard-2" }) await seedItems([child, item]) store.dispose() @@ -470,8 +967,8 @@ describe("TaskHistoryStore upsert transition guard", () => { // Legacy items pre-dating the status field have status: undefined, which normalizes // to "active". Writing status: "active" must not throw as an invalid self-loop. const item = makeItem({ id: "task-guard-legacy" }) - delete (item as any).status - await seedItems([item]) + const { status: _status, ...legacyItem } = item + await seedItems([legacyItem]) store.dispose() store = new TaskHistoryStore(tmpDir) await store.initialize() diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 3b7e9041a4..8ce80e096d 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -57,29 +57,19 @@ describe("TaskHistoryStore", () => { expect(store.getAll()).toEqual([]) }) - it("initializes from existing index file", async () => { + it("initializes from existing per-task files", async () => { const tasksDir = path.join(tmpDir, "tasks") await fs.mkdir(tasksDir, { recursive: true }) const item1 = makeHistoryItem({ id: "task-1", ts: 1000 }) const item2 = makeHistoryItem({ id: "task-2", ts: 2000 }) - // Create task directories so reconciliation doesn't remove them await fs.mkdir(path.join(tasksDir, "task-1"), { recursive: true }) await fs.mkdir(path.join(tasksDir, "task-2"), { recursive: true }) - // Write per-task files await fs.writeFile(path.join(tasksDir, "task-1", GlobalFileNames.historyItem), JSON.stringify(item1)) await fs.writeFile(path.join(tasksDir, "task-2", GlobalFileNames.historyItem), JSON.stringify(item2)) - // Write index - const index = { - version: 1, - updatedAt: Date.now(), - entries: [item1, item2], - } - await fs.writeFile(path.join(tasksDir, GlobalFileNames.historyIndex), JSON.stringify(index)) - await store.initialize() expect(store.getAll()).toHaveLength(2) @@ -373,39 +363,45 @@ describe("TaskHistoryStore", () => { expect(store.get("idem-task")).toBeDefined() }) - }) - - describe("flushIndex()", () => { - it("writes index to disk on flush", async () => { - await store.initialize() - - await store.upsert(makeHistoryItem({ id: "flush-task" })) - await store.flushIndex() - const indexPath = path.join(tmpDir, "tasks", GlobalFileNames.historyIndex) - const raw = await fs.readFile(indexPath, "utf8") - const index = JSON.parse(raw) - - expect(index.version).toBe(1) - expect(index.entries).toHaveLength(1) - expect(index.entries[0].id).toBe("flush-task") - }) - }) + it("serializes migration cache updates behind the store lock", async () => { + const tasksDir = path.join(tmpDir, "tasks") + const migrated = makeHistoryItem({ id: "migration-locked" }) + const concurrent = makeHistoryItem({ id: "migration-concurrent" }) + const migratedFile = path.join(tasksDir, migrated.id, GlobalFileNames.historyItem) + await fs.mkdir(path.dirname(migratedFile), { recursive: true }) + + let releaseMigrationWrite!: () => void + const migrationWriteCanFinish = new Promise((resolve) => { + releaseMigrationWrite = resolve + }) + let signalMigrationWriteStarted!: () => void + const migrationWriteStarted = new Promise((resolve) => { + signalMigrationWriteStarted = resolve + }) - describe("dispose()", () => { - it("flushes index on dispose", async () => { - await store.initialize() + const { safeWriteJson: mockSafeWriteJson } = await import("../../../utils/safeWriteJson") + const originalImpl = vi.mocked(mockSafeWriteJson).getMockImplementation()! + let firstCall = true + vi.mocked(mockSafeWriteJson).mockImplementation(async (...args) => { + if (firstCall) { + firstCall = false + signalMigrationWriteStarted() + await migrationWriteCanFinish + } + return originalImpl(...args) + }) - await store.upsert(makeHistoryItem({ id: "dispose-task" })) - store.dispose() + const migration = store.migrateFromGlobalState([migrated]) + await migrationWriteStarted + const concurrentUpsert = store.upsert(concurrent) - // Give the flush a moment to complete - await new Promise((resolve) => setTimeout(resolve, 100)) + expect(store.get(concurrent.id)).toBeUndefined() + releaseMigrationWrite() + await Promise.all([migration, concurrentUpsert]) - const indexPath = path.join(tmpDir, "tasks", GlobalFileNames.historyIndex) - const raw = await fs.readFile(indexPath, "utf8") - const index = JSON.parse(raw) - expect(index.entries).toHaveLength(1) + expect(store.get(migrated.id)).toEqual(migrated) + expect(store.get(concurrent.id)).toEqual(concurrent) }) }) @@ -708,8 +704,9 @@ describe("TaskHistoryStore", () => { const parentDisk = JSON.parse(await fs.readFile(parentFile, "utf8")) expect(parentDisk.status).toBe("delegated") - // Cache was NOT updated (cache set is deferred until after both writes succeed) - expect(store.get("child-partial")?.status).toBe("active") + // First record's cache IS updated (it was committed to disk). + // Second record's cache is unchanged (write never completed). + expect(store.get("child-partial")?.status).toBe("completed") expect(store.get("parent-partial")?.status).toBe("delegated") }) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b728e43b9a..4be087394e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -570,12 +570,9 @@ export class Task extends EventEmitter implements TaskLike { this.emit(RooCodeEventName.QueuedMessagesUpdated, this.taskId, this.messageQueueService.messages) void this.providerRef .deref() - ?.postStateToWebviewWithoutTaskHistory() + ?.postStateToWebviewThrottled() .catch((error) => { - console.error( - "[Task#messageQueueStateChangedHandler] postStateToWebviewWithoutTaskHistory failed:", - error, - ) + console.error("[Task#messageQueueStateChangedHandler] postStateToWebviewThrottled failed:", error) }) } @@ -1037,9 +1034,21 @@ export class Task extends EventEmitter implements TaskLike { private async addToClineMessages(message: ClineMessage) { this.clineMessages.push(message) const provider = this.providerRef.deref() - // Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update. - // taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated. - await provider?.postStateToWebviewWithoutTaskHistory() + // Unanswered asks must reach the webview before Message listeners can respond against its state. + const requiresImmediateState = + message.partial === true || (message.type === "ask" && message.isAnswered !== true) + try { + await provider?.postStateToWebviewThrottled() + } catch (error) { + console.error("[Task#addToClineMessages] postStateToWebviewThrottled failed:", error) + } + if (requiresImmediateState) { + try { + await provider?.flushPostStateToWebviewThrottled() + } catch (error) { + console.error("[Task#addToClineMessages] flushPostStateToWebviewThrottled failed:", error) + } + } this.emit(RooCodeEventName.Message, { action: "created", message }) await this.saveClineMessages() @@ -2250,6 +2259,15 @@ export class Task extends EventEmitter implements TaskLike { // Force final token usage update before abort event this.emitFinalTokenUsageUpdate() + try { + await this.providerRef.deref()?.flushPostStateToWebviewThrottled() + } catch (error) { + console.error( + `[Task#abortTask] flushPostStateToWebviewThrottled failed for ${this.taskId}.${this.instanceId}:`, + error, + ) + } + this.emit(RooCodeEventName.TaskAborted) try { diff --git a/src/core/task/__tests__/Task.dispose.test.ts b/src/core/task/__tests__/Task.dispose.test.ts index bc14edb366..9f00e9d852 100644 --- a/src/core/task/__tests__/Task.dispose.test.ts +++ b/src/core/task/__tests__/Task.dispose.test.ts @@ -2,6 +2,7 @@ import { type ProviderSettings, RooCodeEventName } from "@roo-code/types" import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Mock dependencies vi.mock("../../webview/ClineProvider") @@ -67,7 +68,7 @@ describe("Task dispose method", () => { // Mock API configuration mockApiConfiguration = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } as ProviderSettings @@ -226,7 +227,7 @@ describe("Task.run() idempotency", () => { beforeEach(() => { vi.clearAllMocks() mockProvider = buildMockProvider() - mockApiConfiguration = { apiProvider: "anthropic", apiKey: "test-key" } as ProviderSettings + mockApiConfiguration = { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key" } as ProviderSettings }) test("run() does not invoke startTask when task was already started by constructor", async () => { diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 60510a71d1..19bd0c7f34 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -10,6 +10,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" import { ContextProxy } from "../../config/ContextProxy" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" type TaskPersistenceAccess = { resumeTaskFromHistory: () => Promise @@ -272,7 +273,7 @@ describe("Task persistence", () => { ) as ClineProvider & Record mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } diff --git a/src/core/task/__tests__/Task.resume-eviction-race.spec.ts b/src/core/task/__tests__/Task.resume-eviction-race.spec.ts index 31e532e0e9..8766f38d5b 100644 --- a/src/core/task/__tests__/Task.resume-eviction-race.spec.ts +++ b/src/core/task/__tests__/Task.resume-eviction-race.spec.ts @@ -19,6 +19,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // ─── Hoisted mocks ─────────────────────────────────────────────────────────── @@ -161,7 +162,7 @@ describe("Task resume/eviction race (Work #1 (no message) regression)", () => { } mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 60bc2f3192..37e228f887 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -9,6 +9,7 @@ import type { Mock } from "vitest" import { providerIdentifiers, + RooCodeEventName, type GlobalState, type ProviderSettings, type ModelInfo, @@ -29,9 +30,12 @@ import type { ApiMessage } from "../../task-persistence" type TaskTestAccess = { getSystemPrompt: () => Promise + getEnabledMcpToolsCount: () => Promise<{ enabledToolCount: number; enabledServerCount: number }> + initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise startTask: (task?: string, images?: string[]) => Promise resumeTaskFromHistory: () => Promise presentAssistantMessageSafe: () => void + addToClineMessages: (message: import("@roo-code/types").ClineMessage) => Promise updateClineMessage: (message: import("@roo-code/types").ClineMessage) => Promise saveClineMessages: () => Promise safeEnsureModelFetched: () => Promise @@ -362,6 +366,8 @@ describe("Cline", () => { mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined) + mockProvider.flushPostStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined) mockProvider.getTaskWithId = vi.fn().mockImplementation(async (id) => ({ historyItem: { id, @@ -1227,6 +1233,8 @@ describe("Cline", () => { say: vi.fn(), postStateToWebview: vi.fn().mockResolvedValue(undefined), postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), updateTaskHistory: vi.fn().mockResolvedValue(undefined), // Task receives a full ClineProvider at runtime; this focused unit test only exercises these methods. @@ -1916,6 +1924,180 @@ describe("Cline", () => { }) }) + describe("webview state throttling", () => { + it("schedules a complete new message without forcing an immediate state push", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + const message = { + ts: Date.now(), + type: "say" as const, + say: "text" as const, + text: "message", + } + + await getTaskTestAccess(task).addToClineMessages(message) + + expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce() + expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() + expect(mockProvider.flushPostStateToWebviewThrottled).not.toHaveBeenCalled() + expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() + }) + + it("waits for an unanswered ask flush before emitting the message", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) + let releaseFlush!: () => void + const pendingFlush = new Promise((resolve) => { + releaseFlush = resolve + }) + const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockReturnValueOnce(pendingFlush) + const messageListener = vi.fn() + task.on(RooCodeEventName.Message, messageListener) + const message = { + ts: 1, + type: "ask" as const, + ask: "resume_task" as const, + } + + const addPromise = taskAccess.addToClineMessages(message) + + await Promise.resolve() + expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce() + expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() + expect(flushSpy).toHaveBeenCalledOnce() + expect(flushSpy).toHaveBeenCalledWith() + expect(messageListener).not.toHaveBeenCalled() + + releaseFlush() + await addPromise + + expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) + expect(messageListener).toHaveBeenCalledWith({ action: "created", message }) + }) + + it("continues the message lifecycle when throttled state scheduling and flushing fail", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const postError = new Error("state schedule failed") + const flushError = new Error("state flush failed") + const postSpy = vi.mocked(mockProvider.postStateToWebviewThrottled).mockRejectedValueOnce(postError) + const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockRejectedValueOnce(flushError) + const saveSpy = vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) + const messageListener = vi.fn() + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + task.on(RooCodeEventName.Message, messageListener) + const message = { + ts: 1, + type: "ask" as const, + ask: "resume_task" as const, + } + + await expect(taskAccess.addToClineMessages(message)).resolves.toBeUndefined() + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Task#addToClineMessages] postStateToWebviewThrottled failed:", + postError, + ) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Task#addToClineMessages] flushPostStateToWebviewThrottled failed:", + flushError, + ) + expect(postSpy).toHaveBeenCalledOnce() + expect(flushSpy).toHaveBeenCalledOnce() + expect(messageListener).toHaveBeenCalledWith({ action: "created", message }) + expect(saveSpy).toHaveBeenCalledOnce() + expect(postSpy.mock.invocationCallOrder[0]).toBeLessThan(flushSpy.mock.invocationCallOrder[0]) + expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) + expect(messageListener.mock.invocationCallOrder[0]).toBeLessThan(saveSpy.mock.invocationCallOrder[0]) + + consoleErrorSpy.mockRestore() + }) + + it("keeps an already answered ask on the throttled path", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + + await getTaskTestAccess(task).addToClineMessages({ + ts: 1, + type: "ask", + ask: "tool", + isAnswered: true, + }) + + expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce() + expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() + expect(mockProvider.flushPostStateToWebviewThrottled).not.toHaveBeenCalled() + }) + + it("waits for a new partial message flush before a following message update", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) + let releaseFlush!: () => void + const pendingFlush = new Promise((resolve) => { + releaseFlush = resolve + }) + const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockReturnValueOnce(pendingFlush) + const updatePostSpy = vi.mocked(mockProvider.postMessageToWebview) + const partialMessage = { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "partial message", + partial: true, + } + let partialAddSettled = false + const addThenUpdate = taskAccess.addToClineMessages(partialMessage).then(async () => { + partialAddSettled = true + await taskAccess.updateClineMessage({ ...partialMessage, text: "updated partial" }) + }) + + await Promise.resolve() + expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() + expect(flushSpy).toHaveBeenCalledWith() + expect(partialAddSettled).toBe(false) + expect(updatePostSpy).not.toHaveBeenCalled() + + releaseFlush() + await addThenUpdate + + expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(updatePostSpy.mock.invocationCallOrder[0]) + expect(updatePostSpy).toHaveBeenCalledWith({ + type: "messageUpdated", + clineMessage: { + ...partialMessage, + text: "updated partial", + }, + }) + }) + }) + describe("abortTask", () => { it("should set abort flag and emit TaskAborted event", async () => { const task = new Task({ @@ -1960,6 +2142,69 @@ describe("Cline", () => { expect(disposeSpy).toHaveBeenCalled() }) + it("flushes pending state before TaskAborted and disposal while queue state is intact", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + let queuedMessagesAtFlush = -1 + const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockImplementation(async () => { + queuedMessagesAtFlush = task.messageQueueService.messages.length + }) + const emitSpy = vi.spyOn(task, "emit") + const disposeSpy = vi.spyOn(task, "dispose").mockImplementation(() => {}) + + task.messageQueueService.addMessage("queued text") + await task.abortTask() + + const taskAbortedCallIndex = (emitSpy.mock.calls as unknown[][]).findIndex( + ([event]) => event === RooCodeEventName.TaskAborted, + ) + expect(taskAbortedCallIndex).toBeGreaterThanOrEqual(0) + expect(queuedMessagesAtFlush).toBe(1) + expect(flushSpy).toHaveBeenCalledWith() + expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan( + emitSpy.mock.invocationCallOrder[taskAbortedCallIndex], + ) + expect(emitSpy.mock.invocationCallOrder[taskAbortedCallIndex]).toBeLessThan( + disposeSpy.mock.invocationCallOrder[0], + ) + }) + + it("continues abort cleanup when flushing pending state fails", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const error = new Error("state flush failed") + const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockRejectedValueOnce(error) + const taskAbortedListener = vi.fn() + const disposeSpy = vi.spyOn(task, "dispose").mockImplementation(() => {}) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + task.on(RooCodeEventName.TaskAborted, taskAbortedListener) + + await expect(task.abortTask()).resolves.toBeUndefined() + + expect(consoleErrorSpy).toHaveBeenCalledWith( + `[Task#abortTask] flushPostStateToWebviewThrottled failed for ${task.taskId}.${task.instanceId}:`, + error, + ) + expect(task.abort).toBe(true) + expect(flushSpy).toHaveBeenCalledOnce() + expect(taskAbortedListener).toHaveBeenCalledOnce() + expect(disposeSpy).toHaveBeenCalledOnce() + expect(saveSpy).toHaveBeenCalledOnce() + expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(taskAbortedListener.mock.invocationCallOrder[0]) + expect(taskAbortedListener.mock.invocationCallOrder[0]).toBeLessThan(disposeSpy.mock.invocationCallOrder[0]) + + consoleErrorSpy.mockRestore() + }) + it("should work with TaskLike interface", async () => { const task = new Task({ provider: mockProvider, @@ -3000,6 +3245,50 @@ describe("Cline", () => { }) }) + describe("startTask", () => { + it("posts a clean state immediately before adding the first task message", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "new task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + + task.clineMessages = [{ ts: 1, type: "say", say: "text", text: "stale message" }] + + let resolvePostState: (() => void) | undefined + const pendingPostState = new Promise((resolve) => { + resolvePostState = resolve + }) + const postStateSpy = vi + .mocked(mockProvider.postStateToWebviewWithoutTaskHistory) + .mockImplementationOnce(async () => { + expect(task.clineMessages).toEqual([]) + await pendingPostState + }) + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + vi.spyOn(taskAccess, "getEnabledMcpToolsCount").mockResolvedValue({ + enabledToolCount: 0, + enabledServerCount: 0, + }) + const initiateTaskLoopSpy = vi.spyOn(taskAccess, "initiateTaskLoop").mockResolvedValue(undefined) + + const startPromise = taskAccess.startTask("new task") + + expect(postStateSpy).toHaveBeenCalledTimes(1) + expect(mockProvider.postStateToWebviewThrottled).not.toHaveBeenCalled() + expect(saySpy).not.toHaveBeenCalled() + + resolvePostState?.() + await startPromise + + expect(saySpy).toHaveBeenCalledOnce() + expect(saySpy).toHaveBeenCalledWith("text", "new task", undefined) + expect(initiateTaskLoopSpy).toHaveBeenCalledOnce() + }) + }) + describe("start()", () => { it("should be a no-op if the task was already started in the constructor", () => { const task = new Task({ @@ -3118,9 +3407,9 @@ describe("Cline", () => { resumeSpy.mockRestore() }) - it("logs (instead of crashing) when postStateToWebviewWithoutTaskHistory rejects from the queue handler", async () => { + it("logs (instead of crashing) when postStateToWebviewThrottled rejects from the queue handler", async () => { const boom = new Error("postState boom") - mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockRejectedValue(boom) + mockProvider.postStateToWebviewThrottled = vi.fn().mockRejectedValue(boom) const task = new Task({ provider: mockProvider, @@ -3129,13 +3418,14 @@ describe("Cline", () => { startTask: false, }) - // Triggers messageQueueStateChangedHandler -> void postStateToWebviewWithoutTaskHistory() + // Triggers messageQueueStateChangedHandler -> void postStateToWebviewThrottled() task.messageQueueService.addMessage("queued text") await flushMicrotasks() - expect(mockProvider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalled() + expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() + expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith( - "[Task#messageQueueStateChangedHandler] postStateToWebviewWithoutTaskHistory failed:", + "[Task#messageQueueStateChangedHandler] postStateToWebviewThrottled failed:", boom, ) }) @@ -3605,7 +3895,7 @@ describe("Telemetry installments (idle/shutdown flush)", () => { mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } diff --git a/src/core/task/__tests__/Task.sticky-profile-race.spec.ts b/src/core/task/__tests__/Task.sticky-profile-race.spec.ts index ea0cd2cffc..d0b4e4cdde 100644 --- a/src/core/task/__tests__/Task.sticky-profile-race.spec.ts +++ b/src/core/task/__tests__/Task.sticky-profile-race.spec.ts @@ -5,6 +5,7 @@ import * as vscode from "vscode" import type { ProviderSettings } from "@roo-code/types" import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { @@ -104,7 +105,7 @@ vi.mock("delay", () => ({ describe("Task - sticky provider profile init race", () => { it("does not overwrite task apiConfigName if set during async initialization", async () => { const apiConfig: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } as any diff --git a/src/core/task/__tests__/Task.throttle.test.ts b/src/core/task/__tests__/Task.throttle.test.ts index 34d78a4ef9..eaacb32faf 100644 --- a/src/core/task/__tests__/Task.throttle.test.ts +++ b/src/core/task/__tests__/Task.throttle.test.ts @@ -3,6 +3,7 @@ import { RooCodeEventName, ProviderSettings, TokenUsage, ToolUsage } from "@roo- import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" import { hasToolUsageChanged, hasTokenUsageChanged } from "../../../shared/getApiMetrics" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Mock dependencies vi.mock("../../webview/ClineProvider") @@ -79,12 +80,14 @@ describe("Task token usage throttling", () => { log: vi.fn(), postStateToWebview: vi.fn().mockResolvedValue(undefined), postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), updateTaskHistory: vi.fn().mockResolvedValue(undefined), } // Mock API configuration mockApiConfiguration = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } as ProviderSettings diff --git a/src/core/task/__tests__/apiConversationHistory.spec.ts b/src/core/task/__tests__/apiConversationHistory.spec.ts index 76a3ac69fd..7313e4fa1c 100644 --- a/src/core/task/__tests__/apiConversationHistory.spec.ts +++ b/src/core/task/__tests__/apiConversationHistory.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { prepareApiConversationMessage } from "../apiConversationHistory.js" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" describe("prepareApiConversationMessage", () => { beforeEach(() => { @@ -20,7 +21,7 @@ describe("prepareApiConversationMessage", () => { getResponseId: () => "response-1", getThoughtSignature: () => "signature-1", } as any, - apiConfiguration: { apiProvider: "anthropic", apiModelId: "claude-3-5-sonnet" } as any, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet" } as any, apiConversationHistory: [], }) as any @@ -40,7 +41,7 @@ describe("prepareApiConversationMessage", () => { message: { role: "assistant", content: "answer" }, reasoning: "visible reasoning", api: {} as any, - apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4" } as any, apiConversationHistory: [], }) as any @@ -55,7 +56,7 @@ describe("prepareApiConversationMessage", () => { message: { role: "assistant", content: "answer" }, reasoning: "private reasoning", api: {} as any, - apiConfiguration: { apiProvider: "anthropic", apiModelId: "claude-3-5-sonnet" } as any, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet" } as any, apiConversationHistory: [], }) as any @@ -72,7 +73,7 @@ describe("prepareApiConversationMessage", () => { api: { getEncryptedContent: () => ({ encrypted_content: "encrypted", id: "reasoning-1" }), } as any, - apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4" } as any, apiConversationHistory: [], }) as any @@ -89,7 +90,7 @@ describe("prepareApiConversationMessage", () => { getThoughtSignature: () => "signature-1", getReasoningDetails: () => [{ type: "reasoning", text: "detail" }], } as any, - apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4" } as any, apiConversationHistory: [], }) as any @@ -107,7 +108,7 @@ describe("prepareApiConversationMessage", () => { content: [{ type: "tool_result", tool_use_id: "wrong-id", content: "done" }], }, api: {} as any, - apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4" } as any, apiConversationHistory: [ { role: "assistant", @@ -130,7 +131,7 @@ describe("prepareApiConversationMessage", () => { ], }, api: {} as any, - apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4" } as any, apiConversationHistory: [{ role: "user", content: "previous user message" } as any], }) as any diff --git a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts index 285f22189e..80c5163c8e 100644 --- a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts +++ b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts @@ -10,6 +10,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" import { ContextProxy } from "../../config/ContextProxy" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Mock delay before any imports that might use it vi.mock("delay", () => ({ @@ -213,7 +214,7 @@ describe("flushPendingToolResultsToHistory", () => { ) as any mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } diff --git a/src/core/task/__tests__/grace-retry-errors.spec.ts b/src/core/task/__tests__/grace-retry-errors.spec.ts index 45c86d92ec..9584559c8f 100644 --- a/src/core/task/__tests__/grace-retry-errors.spec.ts +++ b/src/core/task/__tests__/grace-retry-errors.spec.ts @@ -10,6 +10,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" import { ContextProxy } from "../../config/ContextProxy" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Mock @roo-code/core vi.mock("@roo-code/core", () => ({ @@ -201,7 +202,7 @@ describe("Grace Retry Error Handling", () => { ) as any mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } diff --git a/src/core/task/__tests__/grounding-sources.test.ts b/src/core/task/__tests__/grounding-sources.test.ts index dcb1408baf..d392c90437 100644 --- a/src/core/task/__tests__/grounding-sources.test.ts +++ b/src/core/task/__tests__/grounding-sources.test.ts @@ -155,6 +155,7 @@ vi.mock("../../../utils/fs", () => ({ // Import Task AFTER all vi.mock() calls - Vitest hoists mocks so this works import { Task } from "../Task" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" describe("Task grounding sources handling", () => { let mockProvider: Partial @@ -179,7 +180,7 @@ describe("Task grounding sources handling", () => { } mockApiConfiguration = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, geminiApiKey: "test-key", } as ProviderSettings }) diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index cd4c3958a3..f34cea1bdc 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -155,6 +155,7 @@ vi.mock("../../../utils/fs", () => ({ // Import Task AFTER all vi.mock() calls - Vitest hoists mocks so this works import { Task } from "../Task" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" describe("Task reasoning preservation", () => { let mockProvider: Partial @@ -179,7 +180,7 @@ describe("Task reasoning preservation", () => { } mockApiConfiguration = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } as ProviderSettings }) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index f2fc4889f8..8383d9a4e1 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -8,6 +8,7 @@ import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE, Persisted import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" +import type { ClineProvider } from "../webview/ClineProvider" import { ToolUse, ToolResponse } from "../../shared/tools" import { formatResponse } from "../prompts/responses" @@ -75,6 +76,12 @@ export function resolveAgentTimeoutMs(timeoutSeconds: number | null | undefined) return process.env.ROO_CLI_RUNTIME === "1" ? 0 : requestedAgentTimeout } +// Fire-and-forget: some call sites are synchronous terminal callbacks that cannot await, +// and postMessageToWebview swallows its own errors, so void is enough. +function postCommandExecutionStatus(provider: ClineProvider | undefined, status: CommandExecutionStatus): void { + void provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) +} + export class ExecuteCommandTool extends BaseTool<"execute_command"> { readonly name = "execute_command" as const @@ -115,7 +122,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { status: "error", message: parseError.message, } - provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(errorStatus) }) + postCommandExecutionStatus(provider, errorStatus) task.didToolFailInCurrentTurn = true pushToolResult(formatResponse.toolError(parseError.message)) return @@ -203,7 +210,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { if (canRetryShellIntegrationError(error)) { // Silent retry via execa — shell startup race, command was not submitted. const status: CommandExecutionStatus = { executionId, status: "fallback" } - provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + postCommandExecutionStatus(provider, status) const [rejected, result] = await executeCommandInTerminal(task, { ...options, @@ -294,7 +301,7 @@ export async function executeCommandInTerminal( // panel immediately (same effect as the retry-fallback path). if (isCmdExeFallback) { const status: CommandExecutionStatus = { executionId, status: "fallback" } - provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + postCommandExecutionStatus(provider, status) } // Get global storage path for persisted output artifacts @@ -394,7 +401,7 @@ export async function executeCommandInTerminal( const compressedOutput = Terminal.compressTerminalOutput(accumulatedOutput) latestCompressedOutput = compressedOutput const status: CommandExecutionStatus = { executionId, status: "output", output: compressedOutput } - provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + postCommandExecutionStatus(provider, status) schedulePartialCommandOutputUpdate() }, onCompleted: async (output: string | undefined) => { @@ -433,11 +440,11 @@ export async function executeCommandInTerminal( }, onShellExecutionStarted: (pid: number | undefined) => { const status: CommandExecutionStatus = { executionId, status: "started", pid, command } - provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + postCommandExecutionStatus(provider, status) }, onShellExecutionComplete: (details: ExitCodeDetails) => { const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode } - provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + postCommandExecutionStatus(provider, status) exitDetails = details }, } @@ -506,7 +513,7 @@ export async function executeCommandInTerminal( } catch (error) { if (isUserTimedOut) { const status: CommandExecutionStatus = { executionId, status: "timeout" } - provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + postCommandExecutionStatus(provider, status) await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds })) task.didToolFailInCurrentTurn = true task.terminalProcess = undefined diff --git a/src/core/tools/GenerateImageTool.ts b/src/core/tools/GenerateImageTool.ts index b036a71977..99a0a8f5b1 100644 --- a/src/core/tools/GenerateImageTool.ts +++ b/src/core/tools/GenerateImageTool.ts @@ -6,6 +6,7 @@ import { IMAGE_GENERATION_MODEL_IDS, IMAGE_GENERATION_MODELS, getImageGenerationProvider, + providerIdentifiers, } from "@roo-code/types" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" @@ -155,7 +156,7 @@ export class GenerateImageTool extends BaseTool<"generate_image"> { // Validate API key for OpenRouter const openRouterApiKey = state?.openRouterImageApiKey - if (imageProvider === "openrouter" && !openRouterApiKey) { + if (imageProvider === providerIdentifiers.openrouter && !openRouterApiKey) { const errorMessage = t("tools:generateImage.openRouterApiKeyRequired") await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) diff --git a/src/core/tools/UpdateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts index 7414b713cf..4317696da6 100644 --- a/src/core/tools/UpdateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -64,13 +64,18 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { approvedTodoList !== undefined && JSON.stringify(normalizedTodos) !== JSON.stringify(approvedTodoList) if (isTodoListChanged) { normalizedTodos = approvedTodoList ?? [] - task.say( - "user_edit_todos", - JSON.stringify({ - tool: "updateTodoList", - todos: normalizedTodos, - }), - ) + // Non-blocking: a failed notification must not abort persisting the edited list. + void task + .say( + "user_edit_todos", + JSON.stringify({ + tool: "updateTodoList", + todos: normalizedTodos, + }), + ) + .catch((error) => { + console.error("[UpdateTodoListTool] Failed to post user_edit_todos:", error) + }) } await setTodoListForTask(task, normalizedTodos) diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index 9b2870060c..cd7c3d469d 100644 --- a/src/core/tools/UseMcpToolTool.ts +++ b/src/core/tools/UseMcpToolTool.ts @@ -287,7 +287,8 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { private async sendExecutionStatus(task: Task, status: McpExecutionStatus): Promise { const clineProvider = await task.providerRef.deref() - clineProvider?.postMessageToWebview({ + // Fire-and-forget: postMessageToWebview swallows its own errors, so void is enough. + void clineProvider?.postMessageToWebview({ type: "mcpExecutionStatus", text: JSON.stringify(status), }) diff --git a/src/core/tools/__tests__/executeCommand.spec.ts b/src/core/tools/__tests__/executeCommand.spec.ts index fd85beb0f4..7146fa930b 100644 --- a/src/core/tools/__tests__/executeCommand.spec.ts +++ b/src/core/tools/__tests__/executeCommand.spec.ts @@ -38,7 +38,7 @@ describe("executeCommand", () => { // Create mock provider mockProvider = { - postMessageToWebview: vitest.fn(), + postMessageToWebview: vitest.fn().mockResolvedValue(undefined), getState: vitest.fn().mockResolvedValue({ terminalShellIntegrationDisabled: false, }), @@ -73,6 +73,12 @@ describe("executeCommand", () => { // Mock TerminalRegistry.getOrCreateTerminal ;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValue(mockTerminal) + vitest.mocked(Terminal.isActiveShellCmdExe).mockReturnValue(false) + }) + + afterEach(() => { + vitest.useRealTimers() + vitest.restoreAllMocks() }) describe("Working Directory Behavior", () => { @@ -89,7 +95,7 @@ describe("executeCommand", () => { mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { // Simulate command completion setTimeout(() => { - callbacks.onCompleted("Command output", mockProcess) + void callbacks.onCompleted("Command output", mockProcess) callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess) }, 0) return mockProcess @@ -128,7 +134,7 @@ describe("executeCommand", () => { .fn() .mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { setTimeout(() => { - callbacks.onCompleted("Command output", mockProcess) + void callbacks.onCompleted("Command output", mockProcess) callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess) }, 0) return mockProcess @@ -160,7 +166,7 @@ describe("executeCommand", () => { .fn() .mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { setTimeout(() => { - callbacks.onCompleted("Command output", mockProcess) + void callbacks.onCompleted("Command output", mockProcess) callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess) }, 0) return mockProcess @@ -190,7 +196,7 @@ describe("executeCommand", () => { mockTerminal.getCurrentWorkingDirectory.mockReturnValue(customCwd) mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { setTimeout(() => { - callbacks.onCompleted("Command output", mockProcess) + void callbacks.onCompleted("Command output", mockProcess) callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess) }, 0) return mockProcess @@ -219,7 +225,7 @@ describe("executeCommand", () => { mockTerminal.getCurrentWorkingDirectory.mockReturnValue(resolvedCwd) mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { setTimeout(() => { - callbacks.onCompleted("Command output", mockProcess) + void callbacks.onCompleted("Command output", mockProcess) callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess) }, 0) return mockProcess @@ -265,10 +271,34 @@ describe("executeCommand", () => { }) describe("Terminal Provider Selection", () => { + it("posts fallback status when cmd.exe requires the Execa provider", async () => { + vitest.spyOn(Terminal, "isActiveShellCmdExe").mockReturnValue(true) + mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { + setTimeout(() => { + void callbacks.onCompleted("Command output", mockProcess) + callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess) + }, 0) + return mockProcess + }) + + await executeCommandInTerminal(mockTask, { + executionId: "test-123", + command: "echo test", + terminalShellIntegrationDisabled: false, + }) + + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "commandExecutionStatus", + text: expect.stringContaining('"status":"fallback"'), + }), + ) + }) + it("should use vscode provider when shell integration is enabled", async () => { mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { setTimeout(() => { - callbacks.onCompleted("Command output", mockProcess) + void callbacks.onCompleted("Command output", mockProcess) callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess) }, 0) return mockProcess @@ -290,7 +320,7 @@ describe("executeCommand", () => { it("should use execa provider when shell integration is disabled", async () => { mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { setTimeout(() => { - callbacks.onCompleted("Command output", mockProcess) + void callbacks.onCompleted("Command output", mockProcess) callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess) }, 0) return mockProcess @@ -311,11 +341,39 @@ describe("executeCommand", () => { }) describe("Command Execution States", () => { + it("posts timeout status when command execution exceeds the user limit", async () => { + vitest.useFakeTimers() + const pendingProcess = Object.assign(new Promise(() => {}), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + mockTerminal.runCommand.mockReturnValue(pendingProcess) + + const executionPromise = executeCommandInTerminal(mockTask, { + executionId: "test-123", + command: "sleep 10", + terminalShellIntegrationDisabled: false, + commandExecutionTimeout: 1_000, + }) + await vitest.advanceTimersByTimeAsync(1_000) + const [rejected, result] = await executionPromise + + expect(rejected).toBe(false) + expect(result).toContain("terminated after exceeding") + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "commandExecutionStatus", + text: expect.stringContaining('"status":"timeout"'), + }), + ) + expect(pendingProcess.abort).toHaveBeenCalled() + }) + it("should handle completed command with exit code 0", async () => { mockTerminal.getCurrentWorkingDirectory.mockReturnValue("/test/project") mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { setTimeout(() => { - callbacks.onCompleted("Command completed successfully", mockProcess) + void callbacks.onCompleted("Command completed successfully", mockProcess) callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess) }, 0) return mockProcess @@ -340,7 +398,7 @@ describe("executeCommand", () => { mockTerminal.getCurrentWorkingDirectory.mockReturnValue("/test/project") mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { setTimeout(() => { - callbacks.onCompleted("Command failed", mockProcess) + void callbacks.onCompleted("Command failed", mockProcess) callbacks.onShellExecutionComplete({ exitCode: 1 }, mockProcess) }, 0) return mockProcess @@ -366,7 +424,7 @@ describe("executeCommand", () => { mockTerminal.getCurrentWorkingDirectory.mockReturnValue("/test/project") mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { setTimeout(() => { - callbacks.onCompleted("Command interrupted", mockProcess) + void callbacks.onCompleted("Command interrupted", mockProcess) callbacks.onShellExecutionComplete( { exitCode: undefined, @@ -411,7 +469,7 @@ describe("executeCommand", () => { getCurrentWorkingDirectory: vitest.fn().mockReturnValue(updatedCwd), runCommand: vitest.fn().mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { setTimeout(() => { - callbacks.onCompleted("Directory changed", mockProcess) + void callbacks.onCompleted("Directory changed", mockProcess) callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess) }, 0) return mockProcess diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 41b22a0e5f..a856b180ca 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -8,6 +8,7 @@ import { formatResponse } from "../../prompts/responses" import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" import { unescapeHtmlEntities } from "../../../utils/text-normalization" import { Terminal } from "../../../integrations/terminal/Terminal" +import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" import type { RooTerminalCallbacks, RooTerminalProcess } from "../../../integrations/terminal/types" // Mock dependencies @@ -97,7 +98,7 @@ describe("executeCommandTool", () => { terminalOutputCharacterLimit: 100000, terminalShellIntegrationDisabled: true, }), - postMessageToWebview: vitest.fn(), + postMessageToWebview: vitest.fn().mockResolvedValue(undefined), }), }, lastMessageTs: Date.now(), @@ -212,6 +213,70 @@ describe("executeCommandTool", () => { }) describe("Error handling", () => { + it("reports command parse errors to the webview", async () => { + const provider = await mockCline.providerRef.deref() + mockToolUse.params.command = 'echo "unterminated' + mockToolUse.nativeArgs = { command: 'echo "unterminated' } + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "commandExecutionStatus", + text: expect.stringContaining('"status":"error"'), + }), + ) + expect(mockAskApproval).not.toHaveBeenCalled() + }) + + it("posts fallback status when retrying a pre-submission shell integration failure", async () => { + const provider = await mockCline.providerRef.deref() + const shellError = new executeCommandModule.ShellIntegrationError("startup failed", false) + const failedProcess = Object.assign(Promise.reject(shellError), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + const successfulProcess = Object.assign(Promise.resolve(), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + // The terminal mock only needs the Promise surface used by this execution path. + const successfulTerminalProcess = successfulProcess as unknown as RooTerminalProcess + + vitest + .mocked(TerminalRegistry.getOrCreateTerminal) + .mockResolvedValueOnce({ + runCommand: vitest.fn().mockReturnValue(failedProcess), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + .mockResolvedValueOnce({ + runCommand: vitest.fn().mockImplementation((_command: string, callbacks: RooTerminalCallbacks) => { + void callbacks.onCompleted?.("", successfulTerminalProcess) + callbacks.onShellExecutionComplete?.({ exitCode: 0 }, successfulTerminalProcess) + return successfulProcess + }), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "commandExecutionStatus", + text: expect.stringContaining('"status":"fallback"'), + }), + ) + expect(TerminalRegistry.getOrCreateTerminal).toHaveBeenCalledTimes(2) + }) + it.each([ [undefined, undefined, "executeCommand.destructiveCommandGuard.blocked"], ["matches a destructive pattern", undefined, "executeCommand.destructiveCommandGuard.blockedWithReason"], @@ -580,7 +645,7 @@ describe("executeCommandTool", () => { mockCline.providerRef.deref.mockResolvedValue({ contextProxy: { getValue: vitest.fn().mockReturnValue(false) }, getState: vitest.fn().mockResolvedValue({ terminalShellIntegrationDisabled: false }), - postMessageToWebview: vitest.fn(), + postMessageToWebview: vitest.fn().mockResolvedValue(undefined), }) vitest.spyOn(Terminal, "isActiveShellCmdExe").mockReturnValue(false) const terminal = await setupControllableTerminal() diff --git a/src/core/tools/__tests__/updateTodoListTool.spec.ts b/src/core/tools/__tests__/updateTodoListTool.spec.ts index ebe0500d66..6700764418 100644 --- a/src/core/tools/__tests__/updateTodoListTool.spec.ts +++ b/src/core/tools/__tests__/updateTodoListTool.spec.ts @@ -1,6 +1,43 @@ import { describe, it, expect, beforeEach, vi } from "vitest" -import { parseMarkdownChecklist } from "../UpdateTodoListTool" +import { parseMarkdownChecklist, setPendingTodoList, updateTodoListTool } from "../UpdateTodoListTool" import { TodoItem } from "@roo-code/types" +import type { Task } from "../../task/Task" +import type { ToolCallbacks } from "../BaseTool" + +describe("UpdateTodoListTool", () => { + it("persists the edited todo list even if the say notification fails", async () => { + const editedTodos: TodoItem[] = [{ id: "edited", content: "Edited task", status: "in_progress" }] + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + const task = { + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + todoList: [], + say: vi.fn().mockRejectedValue(new Error("say failed")), + } as unknown as Task + const callbacks = { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockImplementation(async () => { + setPendingTodoList(editedTodos) + return true + }), + } as unknown as ToolCallbacks + + await updateTodoListTool.execute({ todos: "[ ] Original task" }, task, callbacks) + await new Promise((resolve) => setImmediate(resolve)) + + // Notification is fire-and-forget: persistence happens regardless, and the + // rejection is logged rather than routed to handleError (which would abort). + expect(task.todoList).toEqual(editedTodos) + expect(callbacks.handleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[UpdateTodoListTool] Failed to post user_edit_todos:", + expect.any(Error), + ) + consoleErrorSpy.mockRestore() + }) +}) describe("parseMarkdownChecklist", () => { describe("standard checkbox format (without dash prefix)", () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2263257cd6..0761e21a4f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -6,6 +6,7 @@ import EventEmitter from "events" import { Anthropic } from "@anthropic-ai/sdk" import delay from "delay" import axios from "axios" +import debounce from "lodash.debounce" import pWaitFor from "p-wait-for" import * as vscode from "vscode" @@ -36,6 +37,7 @@ import { type ToolUsage, type ExtensionMessage, type ExtensionState, + type WebviewThemeFixture, type MarketplaceInstalledMetadata, RooCodeEventName, requestyDefaultModelId, @@ -161,6 +163,10 @@ function scheduleTask(scheduler: TaskScheduler, task: Task, source: string): voi .catch((error) => console.error(`[${source}] taskScheduler.schedule failed:`, error)) } +type GetStateOptions = { + includeTaskHistory?: boolean +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -173,6 +179,15 @@ export class ClineProvider private static activeInstances: Set = new Set() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] + private pendingThemeFixtureProbes = new Map< + string, + { + resolve: (fixture: WebviewThemeFixture) => void + reject: (error: Error) => void + timeout: ReturnType + } + >() + private nextThemeFixtureProbeId = 0 private view?: vscode.WebviewView | vscode.WebviewPanel private taskRegistry = new TaskRegistry() private taskScheduler = new TaskScheduler() @@ -189,6 +204,21 @@ export class ClineProvider private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined private _disposed = false + private readonly _postStateToWebviewThrottled = debounce( + async () => { + try { + await this.postStateToWebviewWithoutTaskHistory() + } catch (error) { + this.log( + `[ClineProvider#postStateToWebviewThrottled] Failed to post state: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + }, + 500, + { leading: true, trailing: true, maxWait: 1000 }, + ) private readonly rateLimitClock: RateLimitClock = createRateLimitClock() private recentTasksCache?: string[] @@ -198,6 +228,7 @@ export class ClineProvider private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds private providerProfileMutationQueue = Promise.resolve() + private historyTaskCreationQueue = Promise.resolve() private runDelegationTransition(parentTaskId: string, fn: () => Promise): Promise { this.delegationTransitionLocks ??= new Map() @@ -272,7 +303,7 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number - public readonly latestAnnouncementId = "aug-2026-v3.76.0-dcg-providers-terminal" // v3.76.0 destructive command guard, provider improvements, and terminal execution fix + public readonly latestAnnouncementId = "aug-2026-v3.78.0-models-nanogpt-reliability" // v3.78.0 new models, NanoGPT, and provider/task reliability public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager @@ -732,6 +763,7 @@ export class ClineProvider - https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts */ private clearWebviewResources() { + this.rejectPendingThemeFixtureProbes(new Error("Webview was disposed before the theme fixture probe completed")) while (this.webviewDisposables.length) { const x = this.webviewDisposables.pop() if (x) { @@ -746,6 +778,7 @@ export class ClineProvider } this._disposed = true + this._postStateToWebviewThrottled.cancel() this.log("Disposing ClineProvider...") // Reject any tasks still waiting for a scheduler permit so they don't @@ -943,7 +976,8 @@ export class ClineProvider } webviewView.webview.html = - this.contextProxy.extensionMode === vscode.ExtensionMode.Development + this.contextProxy.extensionMode === vscode.ExtensionMode.Development && + process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" ? await this.getHMRHtmlContent(webviewView.webview) : await this.getHtmlContent(webviewView.webview) @@ -1113,10 +1147,31 @@ export class ClineProvider await this.handleZooCodeCallback(token) } - public async createTaskWithHistoryItem( + public createTaskWithHistoryItem( historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }, options?: { startTask?: boolean }, - ) { + ): Promise { + // History navigation can arrive concurrently (for example, two rapid + // showTaskWithId messages). Serialize the full eviction/installation + // transition so both callers cannot observe the same previous registry + // state and schedule distinct Task instances for one history item. + // Fail forward so one rejected restoration does not poison the queue. + const previous = this.historyTaskCreationQueue ?? Promise.resolve() + const run = previous.then( + () => ClineProvider.prototype.createTaskWithHistoryItemUnlocked.call(this, historyItem, options), + () => ClineProvider.prototype.createTaskWithHistoryItemUnlocked.call(this, historyItem, options), + ) + this.historyTaskCreationQueue = run.then( + () => {}, + () => {}, + ) + return run + } + + private async createTaskWithHistoryItemUnlocked( + historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }, + options?: { startTask?: boolean }, + ): Promise { const isCliRuntime = process.env.ROO_CLI_RUNTIME === "1" // CLI injects runtime provider settings from command flags/env at startup. // Restoring provider profiles from task history can overwrite those @@ -1365,6 +1420,43 @@ export class ClineProvider } } + public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { + if (process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1") { + return Promise.reject(new Error("Theme fixture probing is disabled")) + } + + const requestId = `theme-fixture-${++this.nextThemeFixtureProbeId}` + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingThemeFixtureProbes.delete(requestId) + reject(new Error(`Theme fixture probe timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + this.pendingThemeFixtureProbes.set(requestId, { resolve, reject, timeout }) + void this.postMessageToWebview({ type: "themeFixtureProbeRequest", requestId }) + }) + } + + public resolveWebviewThemeFixtureProbe(requestId: string, fixture: WebviewThemeFixture): void { + const pending = this.pendingThemeFixtureProbes.get(requestId) + if (!pending) { + return + } + + clearTimeout(pending.timeout) + this.pendingThemeFixtureProbes.delete(requestId) + pending.resolve(fixture) + } + + private rejectPendingThemeFixtureProbes(error: Error): void { + for (const pending of this.pendingThemeFixtureProbes.values()) { + clearTimeout(pending.timeout) + pending.reject(error) + } + this.pendingThemeFixtureProbes.clear() + } + private async getHMRHtmlContent(webview: vscode.Webview): Promise { let localPort = "5173" @@ -1970,7 +2062,7 @@ export class ClineProvider const newConfiguration: ProviderSettings = { ...apiConfiguration, - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterApiKey: apiKey, openRouterModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId, } @@ -2016,7 +2108,7 @@ export class ClineProvider if (zooProfiles.length === 0) { // No existing zoo-gateway profile — create the canonical default. const newConfiguration: ProviderSettings = { - apiProvider: "zoo-gateway", + apiProvider: providerIdentifiers.zooGateway, zooSessionToken: token, zooGatewayModelId: apiConfiguration.zooGatewayModelId, zooGatewayBaseUrl: derivedGatewayBaseUrl, @@ -2064,7 +2156,7 @@ export class ClineProvider const newConfiguration: ProviderSettings = { ...apiConfiguration, - apiProvider: "requesty", + apiProvider: providerIdentifiers.requesty, requestyApiKey: code, requestyModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId, } @@ -2286,13 +2378,35 @@ export class ClineProvider * `taskHistoryUpdated` / `taskHistoryItemUpdated`. */ async postStateToWebviewWithoutTaskHistory(): Promise { - const state = await this.getStateToPostToWebview() + const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) this.clineMessagesSeq++ state.clineMessagesSeq = this.clineMessagesSeq const { taskHistory: _omit, ...rest } = state await this.postMessageToWebview({ type: "state", state: rest }) } + /** + * Schedules a debounced state-post attempt. A call made while the debounce timer is active returns + * the result of the most recent invocation, so awaiting this method does not wait for the trailing + * invocation scheduled by that call. Use `flushPostStateToWebviewThrottled()` to force and await any + * pending trailing invocation before continuing. + */ + async postStateToWebviewThrottled(): Promise { + if (this._disposed) { + return + } + + await this._postStateToWebviewThrottled() + } + + async flushPostStateToWebviewThrottled(): Promise { + if (this._disposed) { + return + } + + await this._postStateToWebviewThrottled.flush() + } + /** * Like postStateToWebview but intentionally omits both clineMessages and taskHistory. * @@ -2305,7 +2419,7 @@ export class ClineProvider * (cloud auth, org settings, profiles, etc.) without interfering with task message streaming. */ async postStateToWebviewWithoutClineMessages(): Promise { - const state = await this.getStateToPostToWebview() + const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) const { clineMessages: _omitMessages, taskHistory: _omitHistory, ...rest } = state await this.postMessageToWebview({ type: "state", state: rest }) } @@ -2411,7 +2525,7 @@ export class ClineProvider } } - async getStateToPostToWebview(): Promise { + async getStateToPostToWebview({ includeTaskHistory = true }: GetStateOptions = {}): Promise { // Ensure the store is initialized before reading task history await this.taskHistoryStore.initialized @@ -2440,7 +2554,6 @@ export class ClineProvider ttsSpeed, enableCheckpoints, checkpointTimeout, - taskHistory, soundVolume, writeDelayMs, diffFuzzyThreshold, @@ -2503,7 +2616,7 @@ export class ClineProvider autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles, - } = await this.getState() + } = await this.getState({ includeTaskHistory: false }) let cloudOrganizations: CloudOrganizationMembership[] = [] @@ -2529,6 +2642,7 @@ export class ClineProvider const telemetryKey = process.env.POSTHOG_API_KEY const machineId = vscode.env.machineId + const vscodeTelemetryEnabled = vscode.env.isTelemetryEnabled const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands) const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands) const cwd = this.cwd @@ -2589,7 +2703,9 @@ export class ClineProvider clineMessages: currentTask?.clineMessages || [], currentTaskTodos: currentTask?.todoList || [], messageQueue: currentTask?.messageQueueService?.messages, - taskHistory: this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task), + taskHistory: includeTaskHistory + ? this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task) + : [], soundEnabled: soundEnabled ?? false, ttsEnabled: ttsEnabled ?? false, ttsSpeed: ttsSpeed ?? 1.0, @@ -2630,6 +2746,7 @@ export class ClineProvider telemetrySetting, telemetryKey, machineId, + vscodeTelemetryEnabled, showRooIgnoredFiles: showRooIgnoredFiles ?? false, enableSubfolderRules: enableSubfolderRules ?? false, language: language ?? formatLanguage(vscode.env.language), @@ -2724,7 +2841,7 @@ export class ClineProvider * https://www.eliostruyf.com/devhack-code-extension-storage-options/ */ - async getState(): Promise< + async getState({ includeTaskHistory = true }: GetStateOptions = {}): Promise< Omit< ExtensionState, "clineMessages" | "renderContext" | "hasOpenedModeSelector" | "version" | "shouldShowAnnouncement" @@ -2820,7 +2937,7 @@ export class ClineProvider allowedMaxCost: stateValues.allowedMaxCost, autoCondenseContext: stateValues.autoCondenseContext ?? true, autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, - taskHistory: this.taskHistoryStore.getAll(), + taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll() : [], allowedCommands: stateValues.allowedCommands, deniedCommands: stateValues.deniedCommands, soundEnabled: stateValues.soundEnabled ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index ec64a1adb0..99d254cb9a 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -9,6 +9,7 @@ import { ContextProxy } from "../../config/ContextProxy" import type { Mode } from "../../../shared/modes" import { Task, TaskOptions } from "../../task/Task" import { ClineProvider } from "../ClineProvider" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Mock setup vi.mock("fs/promises", () => ({ @@ -118,7 +119,10 @@ vi.mock("../../task/Task", () => ({ } // Define apiConfiguration as a property so tests can read it Object.defineProperty(mockTask, "apiConfiguration", { - value: options?.apiConfiguration || { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" }, + value: options?.apiConfiguration || { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + }, writable: true, configurable: true, }) @@ -231,23 +235,26 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Mock providerSettingsManager ;(provider as any).providerSettingsManager = { saveConfig: vi.fn().mockResolvedValue("test-id"), - listConfig: vi - .fn() - .mockResolvedValue([ - { name: "test-config", id: "test-id", apiProvider: "openrouter", modelId: "openai/gpt-4" }, - ]), + listConfig: vi.fn().mockResolvedValue([ + { + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + modelId: "openai/gpt-4", + }, + ]), setModeConfig: vi.fn(), getModeConfigId: vi.fn().mockResolvedValue(undefined), activateProfile: vi.fn().mockResolvedValue({ name: "test-config", id: "test-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }), getProfile: vi.fn().mockResolvedValue({ name: "test-config", id: "test-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }), } @@ -267,7 +274,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { defaultTaskOptions = { provider, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }, } @@ -281,7 +288,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const mockTask = new Task({ ...defaultTaskOptions, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }, }) @@ -298,7 +305,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await provider.upsertProviderProfile( "test-config", { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", // Other settings that might change rateLimitSeconds: 5, @@ -310,7 +317,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Verify updateApiConfiguration was called because we force rebuild on explicit save/switch expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith( expect.objectContaining({ - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", rateLimitSeconds: 5, modelTemperature: 0.7, @@ -326,7 +333,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const mockTask = new Task({ ...defaultTaskOptions, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }, }) @@ -343,7 +350,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await provider.upsertProviderProfile( "test-config", { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", }, true, @@ -352,7 +359,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Verify updateApiConfiguration was called since provider changed expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith( expect.objectContaining({ - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", }), ) @@ -362,7 +369,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const mockTask = new Task({ ...defaultTaskOptions, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }, }) @@ -379,7 +386,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await provider.upsertProviderProfile( "test-config", { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "anthropic/claude-3-5-sonnet-20241022", }, true, @@ -388,7 +395,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Verify updateApiConfiguration was called since model changed expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith( expect.objectContaining({ - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "anthropic/claude-3-5-sonnet-20241022", }), ) @@ -401,7 +408,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await provider.upsertProviderProfile( "test-config", { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }, true, @@ -428,7 +435,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { return { name: "first-profile", id: "first-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", } }) @@ -437,7 +444,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { return { name: "second-profile", id: "second-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4.1-mini", } }) @@ -465,7 +472,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { .mockResolvedValueOnce({ name: "second-profile", id: "second-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4.1-mini", }) @@ -489,14 +496,14 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { return { name: "first-profile", id: "first-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", } }) .mockResolvedValueOnce({ name: "second-profile", id: "second-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4.1-mini", }) @@ -535,7 +542,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { return { name: "first-profile", id: "first-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", } }) @@ -564,7 +571,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const mockTask = new Task({ ...defaultTaskOptions, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }, }) @@ -572,17 +579,17 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("ask-id") provider["providerSettingsManager"].listConfig = vi .fn() - .mockResolvedValue([{ name: "ask-profile", id: "ask-id", apiProvider: "openrouter" }]) + .mockResolvedValue([{ name: "ask-profile", id: "ask-id", apiProvider: providerIdentifiers.openrouter }]) provider["providerSettingsManager"].getProfile = vi.fn().mockResolvedValue({ name: "ask-profile", id: "ask-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4.1-mini", }) provider["providerSettingsManager"].activateProfile = vi.fn().mockResolvedValue({ name: "ask-profile", id: "ask-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4.1-mini", }) const emitSpy = vi.spyOn(provider, "emit") @@ -609,7 +616,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const mockTask = new Task({ ...defaultTaskOptions, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", modelTemperature: 0.3, }, @@ -627,7 +634,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { ;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({ name: "test-config", id: "test-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", modelTemperature: 0.9, rateLimitSeconds: 7, @@ -638,7 +645,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Verify updateApiConfiguration was called due to forced rebuild on explicit switch expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith( expect.objectContaining({ - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }), ) @@ -652,7 +659,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const mockTask = new Task({ ...defaultTaskOptions, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }, }) @@ -669,7 +676,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { ;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({ name: "anthropic-config", id: "anthropic-id", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", }) @@ -678,7 +685,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Verify updateApiConfiguration was called expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith( expect.objectContaining({ - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", }), ) @@ -691,7 +698,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const mockTask = new Task({ ...defaultTaskOptions, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }, }) @@ -708,7 +715,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { ;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({ name: "test-config", id: "test-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "anthropic/claude-3-5-sonnet-20241022", }) @@ -717,7 +724,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Verify updateApiConfiguration was called expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith( expect.objectContaining({ - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "anthropic/claude-3-5-sonnet-20241022", }), ) @@ -732,7 +739,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const mockTask = new Task({ ...defaultTaskOptions, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }, }) @@ -749,7 +756,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { ;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({ name: "anthropic-config", id: "anthropic-id", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", }) await provider.activateProviderProfile({ name: "anthropic-config" }) @@ -763,7 +770,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { ;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({ name: "test-config", id: "test-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }) await provider.activateProviderProfile({ name: "test-config" }) @@ -777,18 +784,22 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { describe("getModelId helper", () => { test("correctly extracts model ID from different provider configurations", () => { - expect(getModelId({ apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" })).toBe("openai/gpt-4") - expect(getModelId({ apiProvider: "anthropic", apiModelId: "claude-3-5-sonnet-20241022" })).toBe( - "claude-3-5-sonnet-20241022", + expect(getModelId({ apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4" })).toBe( + "openai/gpt-4", + ) + expect( + getModelId({ apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022" }), + ).toBe("claude-3-5-sonnet-20241022") + expect(getModelId({ apiProvider: providerIdentifiers.openai, openAiModelId: "gpt-4-turbo" })).toBe( + "gpt-4-turbo", ) - expect(getModelId({ apiProvider: "openai", openAiModelId: "gpt-4-turbo" })).toBe("gpt-4-turbo") - expect(getModelId({ apiProvider: "bedrock", apiModelId: "anthropic.claude-v2" })).toBe( + expect(getModelId({ apiProvider: providerIdentifiers.bedrock, apiModelId: "anthropic.claude-v2" })).toBe( "anthropic.claude-v2", ) }) test("returns undefined when no model ID is present", () => { - expect(getModelId({ apiProvider: "anthropic" })).toBeUndefined() + expect(getModelId({ apiProvider: providerIdentifiers.anthropic })).toBeUndefined() expect(getModelId({})).toBeUndefined() }) }) diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index 3513bd3bd5..e0ece4f9f7 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -6,6 +6,7 @@ import { Task } from "../../task/Task" import { TaskRegistry } from "../../task/TaskRegistry" import { ContextProxy } from "../../config/ContextProxy" import type { ProviderSettings, HistoryItem } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" type MockTask = Partial & Pick & { @@ -284,7 +285,7 @@ describe("ClineProvider flicker-free cancel", () => { let consoleErrorSpy: ReturnType const mockApiConfig: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } as ProviderSettings diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts index 07e6b82a64..f42eb401f2 100644 --- a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -4,6 +4,7 @@ import * as vscode from "vscode" import { TelemetryService } from "@roo-code/telemetry" import { ClineProvider } from "../ClineProvider" import { ContextProxy } from "../../config/ContextProxy" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" vi.mock("vscode", () => ({ ExtensionContext: vi.fn(), @@ -344,11 +345,13 @@ describe("ClineProvider - Lock API Config Across Modes", () => { const getModeConfigIdSpy = vi .spyOn(provider.providerSettingsManager, "getModeConfigId") .mockResolvedValue("architect-profile-id") - const listConfigSpy = vi - .spyOn(provider.providerSettingsManager, "listConfig") - .mockResolvedValue([ - { name: "architect-profile", id: "architect-profile-id", apiProvider: "anthropic" }, - ]) + const listConfigSpy = vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { + name: "architect-profile", + id: "architect-profile-id", + apiProvider: providerIdentifiers.anthropic, + }, + ]) const activateProviderProfileSpy = vi .spyOn(provider, "activateProviderProfile") .mockResolvedValue(undefined) @@ -367,16 +370,16 @@ describe("ClineProvider - Lock API Config Across Modes", () => { .spyOn(provider.providerSettingsManager, "getModeConfigId") .mockResolvedValue("architect-profile-id") vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "architect-profile", id: "architect-profile-id", apiProvider: "anthropic" }, + { name: "architect-profile", id: "architect-profile-id", apiProvider: providerIdentifiers.anthropic }, ]) vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ name: "architect-profile", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, }) const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ name: "architect-profile", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, }) await provider.handleModeSwitch("architect") diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 00f848bec4..20b14d4ef1 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -537,7 +537,7 @@ describe("ClineProvider", () => { defaultTaskOptions = { provider, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, }, } @@ -700,7 +700,7 @@ describe("ClineProvider", () => { taskHistory: [], shouldShowAnnouncement: false, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, }, customInstructions: undefined, alwaysAllowReadOnly: false, @@ -709,7 +709,7 @@ describe("ClineProvider", () => { codebaseIndexConfig: { codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderBaseUrl: "", codebaseIndexEmbedderModelId: "", }, @@ -771,6 +771,70 @@ describe("ClineProvider", () => { await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() }) + describe("theme fixture probes", () => { + const fixture = { + themeId: "Default Dark Modern", + bodyClass: "vscode-dark", + variables: { "--vscode-foreground": "#cccccc" }, + } + const originalProbeSetting = process.env.ROO_CODE_THEME_FIXTURE_PROBE + + beforeEach(() => { + process.env.ROO_CODE_THEME_FIXTURE_PROBE = "1" + }) + + afterEach(() => { + if (originalProbeSetting === undefined) { + delete process.env.ROO_CODE_THEME_FIXTURE_PROBE + } else { + process.env.ROO_CODE_THEME_FIXTURE_PROBE = originalProbeSetting + } + vi.useRealTimers() + }) + + test("rejects requests when probing is disabled", async () => { + delete process.env.ROO_CODE_THEME_FIXTURE_PROBE + + await expect(provider.requestWebviewThemeFixture()).rejects.toThrow("Theme fixture probing is disabled") + }) + + test("posts a request and resolves the matching response", async () => { + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const request = provider.requestWebviewThemeFixture() + await Promise.resolve() + const requestId = postMessageSpy.mock.calls[0]?.[0].requestId + const unknownFixture = { ...fixture, themeId: "Unexpected Theme" } + + expect(requestId).toBeTruthy() + expect(postMessageSpy).toHaveBeenCalledWith({ type: "themeFixtureProbeRequest", requestId }) + provider.resolveWebviewThemeFixtureProbe("unknown-request", unknownFixture) + provider.resolveWebviewThemeFixtureProbe(requestId!, fixture) + + await expect(request).resolves.toEqual(fixture) + }) + + test("rejects a request after its timeout", async () => { + vi.useFakeTimers() + vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const request = provider.requestWebviewThemeFixture(100) + const rejection = expect(request).rejects.toThrow("Theme fixture probe timed out after 100ms") + + await vi.advanceTimersByTimeAsync(100) + await rejection + }) + + test("rejects pending requests when webview resources are cleared", async () => { + vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const request = provider.requestWebviewThemeFixture() + const rejection = expect(request).rejects.toThrow( + "Webview was disposed before the theme fixture probe completed", + ) + + provider["clearWebviewResources"]() + await rejection + }) + }) + test("postStateToWebview does not force action navigation for non-compliant MDM state", async () => { const mdmService = { requiresCloudAuth: vi.fn().mockReturnValue(true), @@ -794,6 +858,223 @@ describe("ClineProvider", () => { expect(postMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "action" })) }) + test("postStateToWebviewWithoutTaskHistory waits for the webview post boundary", async () => { + let releasePost!: () => void + const pendingPost = new Promise((resolve) => { + releasePost = resolve + }) + let statePostSettled = false + + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue({ + taskHistory: [], + } as unknown as ExtensionState) + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockReturnValue(pendingPost) + + const statePost = provider.postStateToWebviewWithoutTaskHistory() + void statePost.then(() => { + statePostSettled = true + }) + await Promise.resolve() + + expect(postMessageSpy).toHaveBeenCalledOnce() + expect(statePostSettled).toBe(false) + + releasePost() + await statePost + expect(statePostSettled).toBe(true) + }) + + test.each([ + [ + "postStateToWebviewWithoutTaskHistory", + (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), + ], + [ + "postStateToWebviewWithoutClineMessages", + (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutClineMessages(), + ], + ])("%s skips task history computation", async (_methodName, postState) => { + const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll") + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + await postState(provider) + + expect(getAllSpy).not.toHaveBeenCalled() + expect(postMessageSpy).toHaveBeenCalledOnce() + expect(postMessageSpy.mock.calls[0]?.[0].state).not.toHaveProperty("taskHistory") + }) + + test("getStateToPostToWebview computes task history once after its base state resolves", async () => { + const historyItem = { + id: "history-task", + number: 1, + ts: 1, + task: "History task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const originalGetState = provider.getState.bind(provider) + let baseStateResolved = false + const getStateSpy = vi.spyOn(provider, "getState").mockImplementation(async (options) => { + const state = await originalGetState(options) + baseStateResolved = true + return state + }) + const historyReadPhases: boolean[] = [] + const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll").mockImplementation(() => { + historyReadPhases.push(baseStateResolved) + return [historyItem] + }) + + const state = await provider.getStateToPostToWebview() + + expect(getStateSpy).toHaveBeenCalledOnce() + expect(getStateSpy).toHaveBeenCalledWith({ includeTaskHistory: false }) + expect(getAllSpy).toHaveBeenCalledOnce() + expect(historyReadPhases).toEqual([true]) + expect(state.taskHistory).toEqual([historyItem]) + }) + + describe("postStateToWebviewThrottled", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(async () => { + await provider.dispose() + vi.useRealTimers() + }) + + test("posts on the leading edge and coalesces a burst into one trailing post", async () => { + const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + + await provider.postStateToWebviewThrottled() + await provider.postStateToWebviewThrottled() + await provider.postStateToWebviewThrottled() + + expect(postStateSpy).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(499) + expect(postStateSpy).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + expect(postStateSpy).toHaveBeenCalledTimes(2) + }) + + test("does not starve state posts during continuous updates", async () => { + const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + + await provider.postStateToWebviewThrottled() + await vi.advanceTimersByTimeAsync(400) + await provider.postStateToWebviewThrottled() + await vi.advanceTimersByTimeAsync(400) + await provider.postStateToWebviewThrottled() + await vi.advanceTimersByTimeAsync(199) + + expect(postStateSpy).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + expect(postStateSpy).toHaveBeenCalledTimes(2) + }) + + test("flushes a pending trailing post exactly once and waits for it", async () => { + let releasePost!: () => void + const pendingPost = new Promise((resolve) => { + releasePost = resolve + }) + const postStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutTaskHistory") + .mockResolvedValueOnce(undefined) + .mockReturnValueOnce(pendingPost) + + await provider.postStateToWebviewThrottled() + await provider.postStateToWebviewThrottled() + expect(postStateSpy).toHaveBeenCalledTimes(1) + + let flushSettled = false + const flushPromise = provider.flushPostStateToWebviewThrottled() + void flushPromise.then(() => { + flushSettled = true + }) + await Promise.resolve() + + expect(postStateSpy).toHaveBeenCalledTimes(2) + expect(flushSettled).toBe(false) + + releasePost() + await flushPromise + expect(flushSettled).toBe(true) + + await vi.advanceTimersByTimeAsync(1000) + expect(postStateSpy).toHaveBeenCalledTimes(2) + }) + + test("does not duplicate an idle leading post when flushed", async () => { + const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + + await provider.postStateToWebviewThrottled() + await provider.flushPostStateToWebviewThrottled() + await vi.advanceTimersByTimeAsync(1000) + + expect(postStateSpy).toHaveBeenCalledOnce() + }) + + test("handles state post failures inside the debounced callback", async () => { + const error = new Error("state post failed") + const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) + vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockRejectedValue(error) + + await expect(provider.postStateToWebviewThrottled()).resolves.toBeUndefined() + expect(logSpy).toHaveBeenCalledWith( + "[ClineProvider#postStateToWebviewThrottled] Failed to post state: state post failed", + ) + }) + + test("stringifies non-Error state post failures inside the debounced callback", async () => { + const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) + vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockRejectedValue("state post failed") + + await expect(provider.postStateToWebviewThrottled()).resolves.toBeUndefined() + expect(logSpy).toHaveBeenCalledWith( + "[ClineProvider#postStateToWebviewThrottled] Failed to post state: state post failed", + ) + }) + + test("handles state post failures while flushing a pending trailing post", async () => { + const error = new Error("state post failed") + const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) + const postStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutTaskHistory") + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(error) + + await provider.postStateToWebviewThrottled() + await provider.postStateToWebviewThrottled() + await expect(provider.flushPostStateToWebviewThrottled()).resolves.toBeUndefined() + + expect(postStateSpy).toHaveBeenCalledTimes(2) + expect(logSpy).toHaveBeenCalledWith( + "[ClineProvider#postStateToWebviewThrottled] Failed to post state: state post failed", + ) + }) + + test("cancels pending work on dispose and ignores later schedule or flush calls", async () => { + const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + + await provider.postStateToWebviewThrottled() + await provider.postStateToWebviewThrottled() + expect(postStateSpy).toHaveBeenCalledTimes(1) + + await provider.dispose() + await vi.advanceTimersByTimeAsync(1000) + await provider.postStateToWebviewThrottled() + await provider.flushPostStateToWebviewThrottled() + + expect(postStateSpy).toHaveBeenCalledTimes(1) + }) + }) + test("postMessageToWebview skips postMessage after dispose", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -1011,6 +1292,28 @@ describe("ClineProvider", () => { expect(state).toHaveProperty("writeDelayMs") }) + test("getState and getStateToPostToWebview return the complete NanoGPT configuration", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.nanogpt, + nanoGptApiKey: "nanogpt-secret", + nanoGptModelId: "openai/model", + nanoGptRoutingPreference: "latency", + }) + + const state = await provider.getState() + const postedState = await provider.getStateToPostToWebview() + const expectedConfiguration = { + apiProvider: providerIdentifiers.nanogpt, + nanoGptApiKey: "nanogpt-secret", + nanoGptModelId: "openai/model", + nanoGptRoutingPreference: "latency", + } + + expect(state.apiConfiguration).toMatchObject(expectedConfiguration) + expect(postedState.apiConfiguration).toMatchObject(expectedConfiguration) + }) + test("getState returns the saved destructive command guard setting", async () => { await provider.contextProxy.setValue("destructiveCommandGuardEnabled", true) @@ -1239,7 +1542,11 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] - const profile: ProviderSettingsEntry = { name: "test-config", id: "test-id", apiProvider: "anthropic" } + const profile: ProviderSettingsEntry = { + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.anthropic, + } ;(provider as any).providerSettingsManager = { getModeConfigId: vi.fn().mockResolvedValue("test-id"), @@ -1266,7 +1573,9 @@ describe("ClineProvider", () => { getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() - .mockResolvedValue([{ name: "current-config", id: "current-id", apiProvider: "anthropic" }]), + .mockResolvedValue([ + { name: "current-config", id: "current-id", apiProvider: providerIdentifiers.anthropic }, + ]), setModeConfig: vi.fn(), } as any @@ -1283,7 +1592,11 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] - const profile: ProviderSettingsEntry = { apiProvider: "anthropic", id: "new-id", name: "new-config" } + const profile: ProviderSettingsEntry = { + apiProvider: providerIdentifiers.anthropic, + id: "new-id", + name: "new-config", + } ;(provider as any).providerSettingsManager = { activateProfile: vi.fn().mockResolvedValue(profile), @@ -1309,7 +1622,7 @@ describe("ClineProvider", () => { const profile: ProviderSettingsEntry = { name: "config-by-id", id: "config-id-123", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, } ;(provider as any).providerSettingsManager = { @@ -1483,7 +1796,11 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { - listConfig: vi.fn().mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, + ]), saveConfig: vi.fn().mockResolvedValue("test-id"), setModeConfig: vi.fn(), } as any @@ -1492,7 +1809,7 @@ describe("ClineProvider", () => { await messageHandler({ type: "upsertApiConfiguration", text: "test-config", - apiConfiguration: { apiProvider: "anthropic" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, }) // Should save config as default for current mode @@ -1701,7 +2018,7 @@ describe("ClineProvider", () => { // Test with mcpEnabled: true vi.spyOn(provider, "getState").mockResolvedValueOnce({ apiConfiguration: { - apiProvider: "openrouter" as const, + apiProvider: providerIdentifiers.openrouter, }, mcpEnabled: true, mode: "code" as const, @@ -1725,7 +2042,7 @@ describe("ClineProvider", () => { // Test with mcpEnabled: false vi.spyOn(provider, "getState").mockResolvedValueOnce({ apiConfiguration: { - apiProvider: "openrouter" as const, + apiProvider: providerIdentifiers.openrouter, }, mcpEnabled: false, mode: "code" as const, @@ -1761,7 +2078,7 @@ describe("ClineProvider", () => { // Mock getState to return custom instructions for code mode vi.spyOn(provider, "getState").mockResolvedValue({ apiConfiguration: { - apiProvider: "openrouter" as const, + apiProvider: providerIdentifiers.openrouter, }, customModePrompts: { code: { customInstructions: "Code mode specific instructions" }, @@ -1790,7 +2107,7 @@ describe("ClineProvider", () => { // Mock getState to return architect mode instructions vi.spyOn(provider, "getState").mockResolvedValue({ apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, }, customModePrompts: { architect: { customInstructions: "Architect mode instructions" }, @@ -1825,7 +2142,7 @@ describe("ClineProvider", () => { const profile: ProviderSettingsEntry = { name: "saved-config", id: "saved-config-id", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, } ;(provider as any).providerSettingsManager = { @@ -1856,7 +2173,9 @@ describe("ClineProvider", () => { getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() - .mockResolvedValue([{ name: "current-config", id: "current-id", apiProvider: "anthropic" }]), + .mockResolvedValue([ + { name: "current-config", id: "current-id", apiProvider: providerIdentifiers.anthropic }, + ]), setModeConfig: vi.fn(), } as any @@ -1980,10 +2299,14 @@ describe("ClineProvider", () => { getModeConfigId: vi.fn().mockResolvedValue("config-id"), listConfig: vi .fn() - .mockResolvedValue([{ name: "test-config", id: "config-id", apiProvider: "anthropic" }]), - activateProfile: vi - .fn() - .mockResolvedValue({ name: "test-config", id: "config-id", apiProvider: "anthropic" }), + .mockResolvedValue([ + { name: "test-config", id: "config-id", apiProvider: providerIdentifiers.anthropic }, + ]), + activateProfile: vi.fn().mockResolvedValue({ + name: "test-config", + id: "config-id", + apiProvider: providerIdentifiers.anthropic, + }), } // Spy on log method to verify no warning was logged @@ -2115,7 +2438,9 @@ describe("ClineProvider", () => { getModeConfigId: vi.fn().mockResolvedValue("config-id"), listConfig: vi .fn() - .mockResolvedValue([{ name: "test-config", id: "config-id", apiProvider: "anthropic" }]), + .mockResolvedValue([ + { name: "test-config", id: "config-id", apiProvider: providerIdentifiers.anthropic }, + ]), activateProfile: vi.fn().mockRejectedValue(new Error("Failed to load config")), } @@ -2215,7 +2540,9 @@ describe("ClineProvider", () => { setModeConfig: vi.fn().mockRejectedValue(new Error("Failed to update mode config")), listConfig: vi .fn() - .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + .mockResolvedValue([ + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, + ]), } as any // Mock getState to provide necessary data @@ -2228,7 +2555,7 @@ describe("ClineProvider", () => { await messageHandler({ type: "upsertApiConfiguration", text: "test-config", - apiConfiguration: { apiProvider: "anthropic", apiKey: "test-key" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key" }, }) // Verify error was logged and user was notified @@ -2247,11 +2574,13 @@ describe("ClineProvider", () => { saveConfig: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() - .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + .mockResolvedValue([ + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, + ]), } as any const testApiConfig = { - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } @@ -2267,7 +2596,7 @@ describe("ClineProvider", () => { // Verify state updates expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [ - { name: "test-config", id: "test-id", apiProvider: "anthropic" }, + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]) expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config") @@ -2290,7 +2619,9 @@ describe("ClineProvider", () => { saveConfig: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() - .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + .mockResolvedValue([ + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, + ]), } as any // Setup Task instance with auto-mock from the top of the file @@ -2298,7 +2629,7 @@ describe("ClineProvider", () => { await provider.addClineToStack(mockCline) const testApiConfig = { - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } @@ -2317,7 +2648,7 @@ describe("ClineProvider", () => { // Verify state was still updated expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [ - { name: "test-config", id: "test-id", apiProvider: "anthropic" }, + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]) expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config") }) @@ -2331,11 +2662,13 @@ describe("ClineProvider", () => { saveConfig: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() - .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + .mockResolvedValue([ + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, + ]), } as any const testApiConfig = { - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } @@ -2351,10 +2684,10 @@ describe("ClineProvider", () => { // Verify state updates expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [ - { name: "test-config", id: "test-id", apiProvider: "anthropic" }, + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]) expect(updateGlobalStateSpy).toHaveBeenCalledWith("listApiConfigMeta", [ - { name: "test-config", id: "test-id", apiProvider: "anthropic" }, + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]) }) }) @@ -2946,7 +3279,7 @@ describe("getTelemetryProperties", () => { defaultTaskOptions = { provider, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, }, } @@ -3187,19 +3520,21 @@ describe("ClineProvider - Router Models", () => { await messageHandler({ type: "requestRouterModels" }) // Verify getModels was called for each provider with correct options - expect(getModels).toHaveBeenCalledWith({ provider: "openrouter" }) - expect(getModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) - expect(getModels).toHaveBeenCalledWith({ provider: "unbound" }) - expect(getModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) + expect(getModels).toHaveBeenCalledWith({ provider: providerIdentifiers.openrouter }) + expect(getModels).toHaveBeenCalledWith({ provider: providerIdentifiers.requesty, apiKey: "requesty-key" }) + expect(getModels).toHaveBeenCalledWith({ provider: providerIdentifiers.unbound }) + expect(getModels).toHaveBeenCalledWith({ provider: providerIdentifiers.vercelAiGateway }) expect(getModels).toHaveBeenCalledWith({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "litellm-key", baseUrl: "http://localhost:4000", }) // Opencode Go's /models endpoint is public, so it is fetched like the other no-auth routers. - expect(getModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "opencode-go" })) + expect(getModels).toHaveBeenCalledWith(expect.objectContaining({ provider: providerIdentifiers.opencodeGo })) // Kenari's /models endpoint is public, so it is fetched like the other no-auth routers. - expect(getModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "kenari" })) + expect(getModels).toHaveBeenCalledWith(expect.objectContaining({ provider: providerIdentifiers.kenari })) + // NanoGPT's detailed catalog is public and may be scoped by an optional key. + expect(getModels).toHaveBeenCalledWith({ provider: providerIdentifiers.nanogpt, apiKey: undefined }) // Verify response was sent expect(mockPostMessage).toHaveBeenCalledWith({ @@ -3218,6 +3553,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, @@ -3252,6 +3588,7 @@ describe("ClineProvider - Router Models", () => { .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail .mockResolvedValueOnce(mockModels) // opencode-go (public endpoint) .mockResolvedValueOnce(mockModels) // kenari (public endpoint) + .mockResolvedValueOnce(mockModels) // nanogpt (public endpoint) await messageHandler({ type: "requestRouterModels" }) @@ -3272,6 +3609,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, @@ -3282,14 +3620,14 @@ describe("ClineProvider - Router Models", () => { type: "singleRouterModelFetchResponse", success: false, error: "Requesty API error", - values: { provider: "requesty" }, + values: { provider: providerIdentifiers.requesty }, }) expect(mockPostMessage).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, error: "LiteLLM connection failed", - values: { provider: "litellm" }, + values: { provider: providerIdentifiers.litellm }, }) }) @@ -3322,7 +3660,7 @@ describe("ClineProvider - Router Models", () => { // Verify LiteLLM was called with values from message expect(getModels).toHaveBeenCalledWith({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "message-litellm-key", baseUrl: "http://message-url:4000", }) @@ -3351,7 +3689,7 @@ describe("ClineProvider - Router Models", () => { // Verify LiteLLM was NOT called expect(getModels).not.toHaveBeenCalledWith( expect.objectContaining({ - provider: "litellm", + provider: providerIdentifiers.litellm, }), ) @@ -3372,6 +3710,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, @@ -3400,7 +3739,7 @@ describe("ClineProvider - Router Models", () => { }) expect(getModels).toHaveBeenCalledWith({ - provider: "lmstudio", + provider: providerIdentifiers.lmstudio, baseUrl: "http://localhost:1234", }) }) @@ -3498,7 +3837,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { defaultTaskOptions = { provider, apiConfiguration: { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, }, } @@ -4435,7 +4774,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { apiConfiguration: { zooGatewayModelId: "anthropic/claude-sonnet-4" }, } as any) vi.spyOn(provider.contextProxy, "getProviderSettings").mockReturnValue({ - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, } as any) vi.spyOn(provider.contextProxy, "getValues").mockReturnValue({ currentApiConfigName: "Anthropic", @@ -4453,7 +4792,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { expect(upsertSpy).toHaveBeenCalledWith( "Zoo Gateway", expect.objectContaining({ - apiProvider: "zoo-gateway", + apiProvider: providerIdentifiers.zooGateway, zooSessionToken: "zoo_ext_token", zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1", }), @@ -4466,7 +4805,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { apiConfiguration: { zooGatewayModelId: "anthropic/claude-sonnet-4" }, } as any) vi.spyOn(provider.contextProxy, "getProviderSettings").mockReturnValue({ - apiProvider: "zoo-gateway", + apiProvider: providerIdentifiers.zooGateway, } as any) vi.spyOn(provider.contextProxy, "getValues").mockReturnValue({ currentApiConfigName: "Zoo Gateway", @@ -4476,18 +4815,18 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { listConfig: vi.fn().mockResolvedValue([ - { name: "Zoo Gateway", apiProvider: "zoo-gateway" }, - { name: "Backup Zoo", apiProvider: "zoo-gateway" }, + { name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }, + { name: "Backup Zoo", apiProvider: providerIdentifiers.zooGateway }, ]), getProfile: vi .fn() .mockResolvedValueOnce({ - apiProvider: "zoo-gateway", + apiProvider: providerIdentifiers.zooGateway, zooSessionToken: "old-token", zooGatewayBaseUrl: "https://old.example/api/gateway/v1", }) .mockResolvedValueOnce({ - apiProvider: "zoo-gateway", + apiProvider: providerIdentifiers.zooGateway, zooSessionToken: "old-token", }), saveConfig, @@ -4549,7 +4888,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { - listConfig: vi.fn().mockResolvedValue([{ name: "Zoo Gateway", apiProvider: "zoo-gateway" }]), + listConfig: vi + .fn() + .mockResolvedValue([{ name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }]), getProfile: vi.fn().mockResolvedValue({ zooSessionToken: "current-token", zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1", @@ -4568,7 +4909,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { - listConfig: vi.fn().mockResolvedValue([{ name: "Zoo Gateway", apiProvider: "zoo-gateway" }]), + listConfig: vi + .fn() + .mockResolvedValue([{ name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }]), getProfile: vi.fn().mockResolvedValue({ zooSessionToken: "stale-token", zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1", @@ -4586,7 +4929,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { - listConfig: vi.fn().mockResolvedValue([{ name: "Zoo Gateway", apiProvider: "zoo-gateway" }]), + listConfig: vi + .fn() + .mockResolvedValue([{ name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }]), getProfile: vi.fn().mockResolvedValue({ zooSessionToken: "current-token", zooGatewayBaseUrl: "https://staging.zoocode.dev/api/gateway/v1", diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index e6d8c9325f..fedfa13030 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -6,6 +6,7 @@ import { ClineProvider } from "../ClineProvider" import { ContextProxy } from "../../config/ContextProxy" import { Task } from "../../task/Task" import type { HistoryItem, ProviderName } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" vi.mock("vscode", () => ({ ExtensionContext: vi.fn(), @@ -318,7 +319,7 @@ describe("ClineProvider - Sticky Mode", () => { // Create a mock task const mockTask = new Task({ provider, - apiConfiguration: { apiProvider: "openrouter" }, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, }) // Get the actual taskId from the mock @@ -411,7 +412,7 @@ describe("ClineProvider - Sticky Mode", () => { // Create a mock task with history const mockTask = new Task({ provider, - apiConfiguration: { apiProvider: "openrouter" }, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, }) // Get the actual taskId from the mock @@ -534,7 +535,7 @@ describe("ClineProvider - Sticky Mode", () => { // Create a mock task const mockTask = new Task({ provider, - apiConfiguration: { apiProvider: "openrouter" }, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, }) // Get the actual taskId from the mock @@ -587,7 +588,7 @@ describe("ClineProvider - Sticky Mode", () => { // Create parent task const parentTask = new Task({ provider, - apiConfiguration: { apiProvider: "openrouter" }, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, }) // Get the actual taskId from the mock @@ -636,7 +637,7 @@ describe("ClineProvider - Sticky Mode", () => { // Create a subtask (simulating new_task tool behavior) const subtask = new Task({ provider, - apiConfiguration: { apiProvider: "openrouter" }, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, parentTask: parentTask, }) const subtaskId = (subtask as any).taskId || "subtask-id" @@ -672,7 +673,7 @@ describe("ClineProvider - Sticky Mode", () => { // Create a mock task that throws on save const mockTask = new Task({ provider, - apiConfiguration: { apiProvider: "openrouter" }, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, }) vi.spyOn(mockTask as any, "saveClineMessages").mockRejectedValue(new Error("Save failed")) @@ -724,8 +725,8 @@ describe("ClineProvider - Sticky Mode", () => { it("should restore API configuration when restoring task from history with mode", async () => { // Setup: Configure different API configs for different modes - const codeApiConfig = { apiProvider: "anthropic" as ProviderName, anthropicApiKey: "code-key" } - const architectApiConfig = { apiProvider: "openai" as ProviderName, openAiApiKey: "architect-key" } + const codeApiConfig = { apiProvider: providerIdentifiers.anthropic, anthropicApiKey: "code-key" } + const architectApiConfig = { apiProvider: providerIdentifiers.openai, openAiApiKey: "architect-key" } // Save API configs await provider.upsertProviderProfile("code-config", codeApiConfig) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index c982cf53c0..7d8493fba3 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -5,6 +5,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { ClineProvider } from "../ClineProvider" import { ContextProxy } from "../../config/ContextProxy" import type { HistoryItem } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" vi.mock("vscode", () => ({ ExtensionContext: vi.fn(), @@ -363,12 +364,12 @@ describe("ClineProvider - Sticky Provider Profile", () => { vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ name: "new-profile", id: "new-profile-id", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, }) // Mock providerSettingsManager.listConfig vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" }, + { name: "new-profile", id: "new-profile-id", apiProvider: providerIdentifiers.anthropic }, ]) // Switch provider profile @@ -428,12 +429,12 @@ describe("ClineProvider - Sticky Provider Profile", () => { vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ name: "new-profile", id: "new-profile-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, }) // Mock providerSettingsManager.listConfig vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "new-profile", id: "new-profile-id", apiProvider: "openrouter" }, + { name: "new-profile", id: "new-profile-id", apiProvider: providerIdentifiers.openrouter }, ]) // Switch provider profile @@ -471,11 +472,11 @@ describe("ClineProvider - Sticky Provider Profile", () => { vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ name: "new-profile", id: "new-profile-id", - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, }) vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "new-profile", id: "new-profile-id", apiProvider: "openrouter" }, + { name: "new-profile", id: "new-profile-id", apiProvider: providerIdentifiers.openrouter }, ]) await provider.activateProviderProfile({ name: "new-profile" }) @@ -513,7 +514,7 @@ describe("ClineProvider - Sticky Provider Profile", () => { // Mock providerSettingsManager.listConfig vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "saved-profile", id: "saved-profile-id", apiProvider: "anthropic" }, + { name: "saved-profile", id: "saved-profile-id", apiProvider: providerIdentifiers.anthropic }, ]) // Initialize task with history item @@ -579,7 +580,7 @@ describe("ClineProvider - Sticky Provider Profile", () => { const logSpy = vi.spyOn(provider, "log") vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "saved-profile", id: "saved-profile-id", apiProvider: "anthropic" }, + { name: "saved-profile", id: "saved-profile-id", apiProvider: providerIdentifiers.anthropic }, ]) await provider.createTaskWithHistoryItem(historyItem) @@ -613,7 +614,7 @@ describe("ClineProvider - Sticky Provider Profile", () => { vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("mode-config-id") vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "mode-profile", id: "mode-config-id", apiProvider: "anthropic" }, + { name: "mode-profile", id: "mode-config-id", apiProvider: providerIdentifiers.anthropic }, ]) await provider.createTaskWithHistoryItem(historyItem) @@ -683,8 +684,8 @@ describe("ClineProvider - Sticky Provider Profile", () => { // Mock providerSettingsManager methods vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("mode-config-id") vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "mode-preferred-profile", id: "mode-config-id", apiProvider: "anthropic" }, - { name: "task-specific-profile", id: "task-profile-id", apiProvider: "openai" }, + { name: "mode-preferred-profile", id: "mode-config-id", apiProvider: providerIdentifiers.anthropic }, + { name: "task-specific-profile", id: "task-profile-id", apiProvider: providerIdentifiers.openai }, ]) // Initialize task with history item @@ -768,12 +769,12 @@ describe("ClineProvider - Sticky Provider Profile", () => { vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ name: "new-profile", id: "new-profile-id", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, }) // Mock providerSettingsManager.listConfig vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" }, + { name: "new-profile", id: "new-profile-id", apiProvider: providerIdentifiers.anthropic }, ]) // Trigger a profile switch @@ -869,14 +870,14 @@ describe("ClineProvider - Sticky Provider Profile", () => { vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ name: "profile-c", id: "profile-c-id", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, }) // Mock providerSettingsManager.listConfig vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "profile-a", id: "profile-a-id", apiProvider: "anthropic" }, - { name: "profile-b", id: "profile-b-id", apiProvider: "openai" }, - { name: "profile-c", id: "profile-c-id", apiProvider: "anthropic" }, + { name: "profile-a", id: "profile-a-id", apiProvider: providerIdentifiers.anthropic }, + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.openai }, + { name: "profile-c", id: "profile-c-id", apiProvider: providerIdentifiers.anthropic }, ]) // Switch task 1's profile to profile C @@ -928,12 +929,12 @@ describe("ClineProvider - Sticky Provider Profile", () => { vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ name: "new-profile", id: "new-profile-id", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, }) // Mock providerSettingsManager.listConfig vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" }, + { name: "new-profile", id: "new-profile-id", apiProvider: providerIdentifiers.anthropic }, ]) // Mock log to verify error is logged @@ -996,7 +997,7 @@ describe("ClineProvider - Sticky Provider Profile", () => { // Mock providerSettingsManager.listConfig to return the profile vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "failing-profile", id: "failing-profile-id", apiProvider: "anthropic" }, + { name: "failing-profile", id: "failing-profile-id", apiProvider: providerIdentifiers.anthropic }, ]) // Mock activateProviderProfile to throw error diff --git a/src/core/webview/__tests__/messageEnhancer.test.ts b/src/core/webview/__tests__/messageEnhancer.test.ts index 562824bf7c..964c4ebdef 100644 --- a/src/core/webview/__tests__/messageEnhancer.test.ts +++ b/src/core/webview/__tests__/messageEnhancer.test.ts @@ -4,6 +4,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { MessageEnhancer } from "../messageEnhancer" import * as singleCompletionHandlerModule from "../../../utils/single-completion-handler" import { ProviderSettingsManager } from "../../config/ProviderSettingsManager" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Mock dependencies vi.mock("../../../utils/single-completion-handler") @@ -14,7 +15,7 @@ describe("MessageEnhancer", () => { let mockSingleCompletionHandler: ReturnType Promise>> const mockApiConfiguration: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, apiKey: "test-key", apiModelId: "gpt-4", } @@ -32,7 +33,7 @@ describe("MessageEnhancer", () => { mockProviderSettingsManager = { getProfile: vi.fn().mockResolvedValue({ name: "Enhancement Config", - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "enhancement-key", apiModelId: "claude-3", }), @@ -94,7 +95,7 @@ describe("MessageEnhancer", () => { // Verify the enhancement config was used instead of default const expectedConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "enhancement-key", apiModelId: "claude-3", } diff --git a/src/core/webview/__tests__/telemetrySettingsTracking.spec.ts b/src/core/webview/__tests__/telemetrySettingsTracking.spec.ts index a99c8862a3..0f1e0e1315 100644 --- a/src/core/webview/__tests__/telemetrySettingsTracking.spec.ts +++ b/src/core/webview/__tests__/telemetrySettingsTracking.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { TelemetryService } from "@roo-code/telemetry" -import { TelemetryEventName, type TelemetrySetting } from "@roo-code/types" +import { TelemetryEventName, type TelemetrySetting, isTelemetryOptedIn } from "@roo-code/types" describe("Telemetry Settings Tracking", () => { let mockTelemetryService: { @@ -31,8 +31,8 @@ describe("Telemetry Settings Tracking", () => { const newSetting = "disabled" as TelemetrySetting // Simulate the logic from webviewMessageHandler - const isOptedIn = newSetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + const isOptedIn = isTelemetryOptedIn(newSetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) // If turning telemetry OFF, fire event BEFORE disabling if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { @@ -50,12 +50,12 @@ describe("Telemetry Settings Tracking", () => { expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(false) }) - it("should fire event when going from unset to disabled", () => { + it("should fire an opt-out event when going from unset to disabled (explicit Decline)", () => { const previousSetting = "unset" as TelemetrySetting const newSetting = "disabled" as TelemetrySetting - const isOptedIn = newSetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + const isOptedIn = isTelemetryOptedIn(newSetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, newSetting) @@ -63,7 +63,10 @@ describe("Telemetry Settings Tracking", () => { TelemetryService.instance.updateTelemetryState(isOptedIn) + // "unset" is opted in under the disclosed opt-out default, so unset -> disabled + // is a genuine opt-out transition. expect(mockTelemetryService.captureTelemetrySettingsChanged).toHaveBeenCalledWith("unset", "disabled") + expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(false) }) }) @@ -72,8 +75,8 @@ describe("Telemetry Settings Tracking", () => { const previousSetting = "disabled" as TelemetrySetting const newSetting = "enabled" as TelemetrySetting - const isOptedIn = newSetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + const isOptedIn = isTelemetryOptedIn(newSetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) // Update the telemetry state first TelemetryService.instance.updateTelemetryState(isOptedIn) @@ -95,8 +98,8 @@ describe("Telemetry Settings Tracking", () => { const previousSetting = "enabled" as TelemetrySetting const newSetting = "enabled" as TelemetrySetting - const isOptedIn = newSetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + const isOptedIn = isTelemetryOptedIn(newSetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) // Neither condition should be met if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { @@ -114,14 +117,13 @@ describe("Telemetry Settings Tracking", () => { expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(true) }) - it("should fire event when going from unset to enabled (telemetry banner close)", () => { + it("should not fire an event when going from unset to enabled (already opted in by default)", () => { const previousSetting = "unset" as TelemetrySetting const newSetting = "enabled" as TelemetrySetting - const isOptedIn = newSetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + const isOptedIn = isTelemetryOptedIn(newSetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) - // For unset -> enabled, both are opted in, so no event should fire if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, newSetting) } @@ -132,8 +134,21 @@ describe("Telemetry Settings Tracking", () => { TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, newSetting) } - // unset is treated as opted-in, so no event should fire + // "unset" is already opted in under the disclosed opt-out default, so explicit + // Accept (unset -> enabled) is a no-op transition, not a new opt-in. expect(mockTelemetryService.captureTelemetrySettingsChanged).not.toHaveBeenCalled() + expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(true) + }) + }) + + describe("neutral banner dismiss ('unset' left as-is)", () => { + it("leaves the disclosed opt-out default in effect while the setting remains unset", () => { + // A neutral dismiss of the consent banner sends no telemetrySetting message at + // all, so the stored setting stays "unset". Confirm "unset" alone -- with no + // transition, and no affirmative choice recorded either way -- resolves to the + // disclosed default (telemetry on) rather than silently opting the user in via + // dismissal itself. + expect(isTelemetryOptedIn("unset" as TelemetrySetting)).toBe(true) }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts index df85ff1df4..1f23e353c6 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts @@ -52,7 +52,6 @@ describe("webviewMessageHandler - importRooHistory", () => { taskHistoryStore: { invalidateAll: ReturnType reconcile: ReturnType - flushIndex: ReturnType } postMessageToWebview: ReturnType postStateToWebview: ReturnType @@ -71,7 +70,6 @@ describe("webviewMessageHandler - importRooHistory", () => { taskHistoryStore: { invalidateAll: vi.fn(), reconcile: vi.fn().mockResolvedValue(undefined), - flushIndex: vi.fn().mockResolvedValue(undefined), }, postMessageToWebview: vi.fn().mockResolvedValue(undefined), postStateToWebview: vi.fn().mockResolvedValue(undefined), @@ -106,7 +104,7 @@ describe("webviewMessageHandler - importRooHistory", () => { expect(importRooTaskHistoryMock).toHaveBeenCalledWith("/mock/storage", expect.any(Function)) expect(mockProvider.taskHistoryStore.invalidateAll).toHaveBeenCalledTimes(1) expect(mockProvider.taskHistoryStore.reconcile).toHaveBeenCalledTimes(1) - expect(mockProvider.taskHistoryStore.flushIndex).toHaveBeenCalledTimes(1) + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) expect(mockProvider.postMessageToWebview).toHaveBeenNthCalledWith(1, { type: "rooHistoryImportProgress", @@ -189,7 +187,7 @@ describe("webviewMessageHandler - importRooHistory", () => { expect(importRooTaskHistoryMock).toHaveBeenCalledWith("/mock/storage", expect.any(Function)) expect(mockProvider.taskHistoryStore.invalidateAll).not.toHaveBeenCalled() expect(mockProvider.taskHistoryStore.reconcile).not.toHaveBeenCalled() - expect(mockProvider.taskHistoryStore.flushIndex).not.toHaveBeenCalled() + expect(mockProvider.postStateToWebview).not.toHaveBeenCalled() expect(mockProvider.postMessageToWebview).toHaveBeenNthCalledWith(2, { type: "rooHistoryImportProgress", @@ -222,7 +220,7 @@ describe("webviewMessageHandler - importRooHistory", () => { // after a partial-copy failure still reconciles the store. expect(mockProvider.taskHistoryStore.invalidateAll).toHaveBeenCalledTimes(1) expect(mockProvider.taskHistoryStore.reconcile).toHaveBeenCalledTimes(1) - expect(mockProvider.taskHistoryStore.flushIndex).toHaveBeenCalledTimes(1) + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( "common:warnings.rooHistoryImport.alreadyImported", @@ -237,7 +235,7 @@ describe("webviewMessageHandler - importRooHistory", () => { expect(mockProvider.taskHistoryStore.invalidateAll).not.toHaveBeenCalled() expect(mockProvider.taskHistoryStore.reconcile).not.toHaveBeenCalled() - expect(mockProvider.taskHistoryStore.flushIndex).not.toHaveBeenCalled() + expect(mockProvider.postStateToWebview).not.toHaveBeenCalled() expect(mockProvider.log).toHaveBeenCalledWith("[importRooHistory] failed: permission denied") expect(mockProvider.postMessageToWebview).toHaveBeenNthCalledWith(2, { diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 3ceeb2f895..5a4b3e7be3 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -1,7 +1,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest" + +import { + kimiCodeAuthMethodSchema, + providerIdentifiers, + retiredProviderIdentifiers, + RouterModelsMessageType, +} from "@roo-code/types" + import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" +const [kimiCodeOAuthAuthMethod, kimiCodeApiKeyAuthMethod] = kimiCodeAuthMethodSchema.options + +const { getKimiCodeAccessTokenMock } = vi.hoisted(() => ({ + getKimiCodeAccessTokenMock: vi.fn(), +})) + +vi.mock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + getAccessToken: getKimiCodeAccessTokenMock, + }, +})) + // Mock vscode (minimal) vi.mock("vscode", () => ({ window: { @@ -68,13 +88,13 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { // Default mock: return distinct model maps per provider so we can verify keys getModelsMock.mockImplementation(async (options: any) => { switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -86,10 +106,10 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { await webviewMessageHandler(mockProvider as any, { type: "requestRooModels" } as any) expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", + type: RouterModelsMessageType.singleRouterModelFetchResponse, success: false, error: "Roo Code Router has been removed. Please select and configure a different provider.", - values: { provider: "roo" }, + values: { provider: retiredProviderIdentifiers.roo }, }) }) @@ -97,25 +117,29 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { await webviewMessageHandler( mockProvider as any, { - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, } as any, ) const call = (mockProvider.postMessageToWebview as any).mock.calls.find( - (c: any[]) => c[0]?.type === "routerModels", + (c: any[]) => c[0]?.type === RouterModelsMessageType.routerModels, ) expect(call).toBeTruthy() const routerModels = call[0].routerModels as Record> // Aggregate handler initializes many known routers - ensure a few expected keys exist - expect(routerModels).toHaveProperty("openrouter") - expect(routerModels).toHaveProperty("requesty") - expect(routerModels).toHaveProperty("deepseek") - expect(routerModels).toHaveProperty("moonshot") + expect(routerModels).toHaveProperty(providerIdentifiers.openrouter) + expect(routerModels).toHaveProperty(providerIdentifiers.requesty) + expect(routerModels).toHaveProperty(providerIdentifiers.deepseek) + expect(routerModels).toHaveProperty(providerIdentifiers.moonshot) expect(routerModels.deepseek).toEqual({}) expect(routerModels.moonshot).toEqual({}) - expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: "deepseek" })) - expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: "moonshot" })) + expect(getModelsMock).not.toHaveBeenCalledWith( + expect.objectContaining({ provider: providerIdentifiers.deepseek }), + ) + expect(getModelsMock).not.toHaveBeenCalledWith( + expect.objectContaining({ provider: providerIdentifiers.moonshot }), + ) }) it("fetches DeepSeek models when stored DeepSeek credentials exist", async () => { @@ -127,18 +151,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) getModelsMock.mockImplementation(async (options: any) => { - if (options?.provider === "deepseek") { + if (options?.provider === providerIdentifiers.deepseek) { return { "deepseek-v4-flash": { contextWindow: 1_000_000, supportsPromptCache: true } } } switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -148,18 +172,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { await webviewMessageHandler( mockProvider as any, { - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, } as any, ) expect(getModelsMock).toHaveBeenCalledWith({ - provider: "deepseek", + provider: providerIdentifiers.deepseek, apiKey: "stored-deepseek-key", baseUrl: "https://deepseek.example.com", }) const call = (mockProvider.postMessageToWebview as any).mock.calls.find( - (c: any[]) => c[0]?.type === "routerModels", + (c: any[]) => c[0]?.type === RouterModelsMessageType.routerModels, ) expect(call).toBeTruthy() expect(call[0].routerModels.deepseek).toEqual({ @@ -175,18 +199,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) getModelsMock.mockImplementation(async (options: any) => { - if (options?.provider === "deepseek") { + if (options?.provider === providerIdentifiers.deepseek) { throw new Error("DeepSeek API error") } switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -196,19 +220,19 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { await webviewMessageHandler( mockProvider as any, { - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, } as any, ) expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", + type: RouterModelsMessageType.singleRouterModelFetchResponse, success: false, error: "DeepSeek API error", - values: { provider: "deepseek" }, + values: { provider: providerIdentifiers.deepseek }, }) const call = (mockProvider.postMessageToWebview as any).mock.calls.find( - (c: any[]) => c[0]?.type === "routerModels", + (c: any[]) => c[0]?.type === RouterModelsMessageType.routerModels, ) expect(call).toBeTruthy() expect(call[0].routerModels.deepseek).toEqual({}) @@ -218,23 +242,92 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { await webviewMessageHandler( mockProvider as any, { - type: "requestRouterModels", - values: { provider: "openrouter" }, + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.openrouter }, } as any, ) const call = (mockProvider.postMessageToWebview as any).mock.calls.find( - (c: any[]) => c[0]?.type === "routerModels", + (c: any[]) => c[0]?.type === RouterModelsMessageType.routerModels, ) expect(call).toBeTruthy() const routerModels = call[0].routerModels as Record> const keys = Object.keys(routerModels) - expect(keys).toEqual(["openrouter"]) + expect(keys).toEqual([providerIdentifiers.openrouter]) expect(Object.keys(routerModels.openrouter || {})).toContain("openrouter/qwen2.5") const providersCalled = getModelsMock.mock.calls.map((c: any[]) => c[0]?.provider) - expect(providersCalled).toEqual(["openrouter"]) + expect(providersCalled).toEqual([providerIdentifiers.openrouter]) + }) + + it("filters to Kimi Code and dispatches an explicit API key without reading the OAuth token", async () => { + const kimiModels = { "kimi-for-coding": { contextWindow: 262_144, supportsPromptCache: true } } + getModelsMock.mockResolvedValue(kimiModels) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.kimiCode, + kimiCodeAuthMethod: kimiCodeApiKeyAuthMethod, + kimiCodeApiKey: "preview-kimi-api-key", + }, + }) + + expect(getKimiCodeAccessTokenMock).not.toHaveBeenCalled() + expect(getModelsMock).toHaveBeenCalledTimes(1) + expect(getModelsMock).toHaveBeenCalledWith({ + provider: providerIdentifiers.kimiCode, + apiKey: "preview-kimi-api-key", + }) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels).toEqual({ [providerIdentifiers.kimiCode]: kimiModels }) + }) + + it("filters to Kimi Code and dispatches the OAuth access token", async () => { + getKimiCodeAccessTokenMock.mockResolvedValue("kimi-oauth-token") + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.kimiCode, + kimiCodeAuthMethod: kimiCodeOAuthAuthMethod, + }, + }) + + expect(getKimiCodeAccessTokenMock).toHaveBeenCalledTimes(1) + expect(getModelsMock).toHaveBeenCalledTimes(1) + expect(getModelsMock).toHaveBeenCalledWith({ + provider: providerIdentifiers.kimiCode, + apiKey: "kimi-oauth-token", + }) + }) + + it("excludes Kimi Code from model fetching when OAuth has no access token", async () => { + getKimiCodeAccessTokenMock.mockResolvedValue(null) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.kimiCode, + kimiCodeAuthMethod: kimiCodeOAuthAuthMethod, + }, + }) + + expect(getKimiCodeAccessTokenMock).toHaveBeenCalledTimes(1) + expect(getModelsMock).not.toHaveBeenCalled() + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels).toEqual({}) }) it("flushes cache when LiteLLM credentials are provided in message values", async () => { @@ -242,7 +335,7 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { await webviewMessageHandler( mockProvider as any, { - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { litellmApiKey: "test-api-key", litellmBaseUrl: "http://localhost:4000", @@ -252,15 +345,17 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { // flushModels should have been called for litellm with refresh=true and credentials expect(flushModelsMock).toHaveBeenCalledWith( - { provider: "litellm", apiKey: "test-api-key", baseUrl: "http://localhost:4000" }, + { provider: providerIdentifiers.litellm, apiKey: "test-api-key", baseUrl: "http://localhost:4000" }, true, ) // getModels should have been called with the provided credentials - const litellmCalls = getModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "litellm") + const litellmCalls = getModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.litellm, + ) expect(litellmCalls.length).toBe(1) expect(litellmCalls[0][0]).toEqual({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "test-api-key", baseUrl: "http://localhost:4000", }) @@ -278,24 +373,80 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { await webviewMessageHandler( mockProvider as any, { - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, } as any, ) // flushModels should NOT have been called for litellm - const litellmFlushCalls = flushModelsMock.mock.calls.filter((c: any[]) => c[0] === "litellm") + const litellmFlushCalls = flushModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.litellm, + ) expect(litellmFlushCalls.length).toBe(0) // getModels should still have been called with stored credentials - const litellmCalls = getModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "litellm") + const litellmCalls = getModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.litellm, + ) expect(litellmCalls.length).toBe(1) expect(litellmCalls[0][0]).toEqual({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "stored-api-key", baseUrl: "http://stored:4000", }) }) + it("flushes and fetches Poe models with explicit unsaved credentials", async () => { + const poeModels = { "claude-sonnet": { contextWindow: 200_000, supportsPromptCache: false } } + getModelsMock.mockImplementation(async (options: { provider?: string }) => + options.provider === providerIdentifiers.poe ? poeModels : {}, + ) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + poeApiKey: "new-poe-key", + poeBaseUrl: "https://poe.example.com/v1", + }, + }) + + const poeOptions = { + provider: providerIdentifiers.poe, + apiKey: "new-poe-key", + baseUrl: "https://poe.example.com/v1", + } + expect(flushModelsMock).toHaveBeenCalledWith(poeOptions, true) + expect(getModelsMock).toHaveBeenCalledWith(poeOptions) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.poe).toEqual(poeModels) + }) + + it("flushes DeepSeek models when an unsaved base URL is paired with the stored API key", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + deepSeekApiKey: "stored-deepseek-key", + deepSeekBaseUrl: "https://stored.deepseek.example.com", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { deepSeekBaseUrl: "https://preview.deepseek.example.com" }, + }) + + const deepSeekOptions = { + provider: providerIdentifiers.deepseek, + apiKey: "stored-deepseek-key", + baseUrl: "https://preview.deepseek.example.com", + } + expect(flushModelsMock).toHaveBeenCalledWith(deepSeekOptions, true) + expect(getModelsMock).toHaveBeenCalledWith(deepSeekOptions) + }) + it("fetches Moonshot models when stored Moonshot credentials exist", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { @@ -305,18 +456,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) getModelsMock.mockImplementation(async (options: any) => { - if (options?.provider === "moonshot") { + if (options?.provider === providerIdentifiers.moonshot) { return { "kimi-k2-0905-preview": { contextWindow: 262144, supportsPromptCache: true } } } switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -326,18 +477,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { await webviewMessageHandler( mockProvider as any, { - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, } as any, ) expect(getModelsMock).toHaveBeenCalledWith({ - provider: "moonshot", + provider: providerIdentifiers.moonshot, apiKey: "stored-moonshot-key", baseUrl: "https://api.moonshot.ai/v1", }) const call = (mockProvider.postMessageToWebview as any).mock.calls.find( - (c: any[]) => c[0]?.type === "routerModels", + (c: any[]) => c[0]?.type === RouterModelsMessageType.routerModels, ) expect(call).toBeTruthy() expect(call[0].routerModels.moonshot).toEqual({ @@ -353,7 +504,7 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { await webviewMessageHandler( mockProvider as any, { - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { moonshotApiKey: "new-moonshot-key", moonshotBaseUrl: "https://api.moonshot.cn/v1", @@ -362,19 +513,23 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { ) // flushModels should have been called for moonshot - const moonshotFlushCalls = flushModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + const moonshotFlushCalls = flushModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.moonshot, + ) expect(moonshotFlushCalls.length).toBe(1) expect(moonshotFlushCalls[0][0]).toEqual({ - provider: "moonshot", + provider: providerIdentifiers.moonshot, apiKey: "new-moonshot-key", baseUrl: "https://api.moonshot.cn/v1", }) // getModels should use the provided credentials - const moonshotCalls = getModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + const moonshotCalls = getModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.moonshot, + ) expect(moonshotCalls.length).toBe(1) expect(moonshotCalls[0][0]).toEqual({ - provider: "moonshot", + provider: providerIdentifiers.moonshot, apiKey: "new-moonshot-key", baseUrl: "https://api.moonshot.cn/v1", }) @@ -388,18 +543,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) getModelsMock.mockImplementation(async (options: any) => { - if (options?.provider === "moonshot") { + if (options?.provider === providerIdentifiers.moonshot) { return { "kimi-k2-0905-preview": { contextWindow: 262144, supportsPromptCache: true } } } switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -409,19 +564,23 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { await webviewMessageHandler( mockProvider as any, { - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, } as any, ) // flushModels should NOT have been called for moonshot - const moonshotFlushCalls = flushModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + const moonshotFlushCalls = flushModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.moonshot, + ) expect(moonshotFlushCalls.length).toBe(0) // getModels should still have been called with stored credentials - const moonshotCalls = getModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + const moonshotCalls = getModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.moonshot, + ) expect(moonshotCalls.length).toBe(1) expect(moonshotCalls[0][0]).toEqual({ - provider: "moonshot", + provider: providerIdentifiers.moonshot, apiKey: "stored-moonshot-key", baseUrl: undefined, }) @@ -435,18 +594,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) getModelsMock.mockImplementation(async (options: any) => { - if (options?.provider === "moonshot") { + if (options?.provider === providerIdentifiers.moonshot) { throw new Error("Moonshot API error") } switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -456,20 +615,22 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { await webviewMessageHandler( mockProvider as any, { - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, } as any, ) // Should have posted an error for moonshot const errorCall = (mockProvider.postMessageToWebview as any).mock.calls.find( - (c: any[]) => c[0]?.type === "singleRouterModelFetchResponse" && c[0]?.values?.provider === "moonshot", + (c: any[]) => + c[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse && + c[0]?.values?.provider === providerIdentifiers.moonshot, ) expect(errorCall).toBeTruthy() expect(errorCall[0].success).toBe(false) // Aggregate entry should still be empty const call = (mockProvider.postMessageToWebview as any).mock.calls.find( - (c: any[]) => c[0]?.type === "routerModels", + (c: any[]) => c[0]?.type === RouterModelsMessageType.routerModels, ) expect(call).toBeTruthy() expect(call[0].routerModels.moonshot).toEqual({}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index a3b76aa8b2..9d46894756 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -11,6 +11,10 @@ vi.mock("../../../api/providers/fetchers/lmstudio", () => ({ getLMStudioModels: vi.fn(), })) +vi.mock("../../../integrations/theme/getTheme", () => ({ + getTheme: vi.fn().mockResolvedValue({}), +})) + vi.mock("../../../integrations/openai-codex/oauth", () => ({ openAiCodexOAuthManager: { getAccessToken: vi.fn(), @@ -55,6 +59,16 @@ vi.mock("../rulesMessageHandler", () => ({ handleOpenRulesDirectory: vi.fn(), })) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + hasInstance: vi.fn().mockReturnValue(false), + instance: { + updateTelemetryState: vi.fn(), + captureTelemetrySettingsChanged: vi.fn(), + }, + }, +})) + import type { ModelRecord } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" @@ -103,6 +117,7 @@ const mockClineProvider = { }, log: vi.fn(), postStateToWebview: vi.fn(), + resolveWebviewThemeFixtureProbe: vi.fn(), getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), createTaskWithHistoryItem: vi.fn(), @@ -110,6 +125,50 @@ const mockClineProvider = { cwd: "/mock/workspace", } as unknown as ClineProvider +describe("webviewMessageHandler - theme fixture probes", () => { + const originalProbeSetting = process.env.ROO_CODE_THEME_FIXTURE_PROBE + const themeFixture = { + themeId: "Default Dark Modern", + bodyClass: "vscode-dark", + variables: { "--vscode-foreground": "#cccccc" }, + } + + beforeEach(() => { + vi.clearAllMocks() + process.env.ROO_CODE_THEME_FIXTURE_PROBE = "1" + }) + + afterEach(() => { + if (originalProbeSetting === undefined) { + delete process.env.ROO_CODE_THEME_FIXTURE_PROBE + } else { + process.env.ROO_CODE_THEME_FIXTURE_PROBE = originalProbeSetting + } + }) + + it("resolves a complete response when probing is enabled", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "themeFixtureProbeResponse", + requestId: "request-1", + themeFixture, + }) + + expect(mockClineProvider.resolveWebviewThemeFixtureProbe).toHaveBeenCalledWith("request-1", themeFixture) + }) + + it("ignores incomplete or disabled responses", async () => { + await webviewMessageHandler(mockClineProvider, { type: "themeFixtureProbeResponse" }) + delete process.env.ROO_CODE_THEME_FIXTURE_PROBE + await webviewMessageHandler(mockClineProvider, { + type: "themeFixtureProbeResponse", + requestId: "request-1", + themeFixture, + }) + + expect(mockClineProvider.resolveWebviewThemeFixtureProbe).not.toHaveBeenCalled() + }) +}) + import { t } from "../../../i18n" vi.mock("vscode", () => { @@ -132,6 +191,9 @@ vi.mock("vscode", () => { commands: { executeCommand: vi.fn().mockResolvedValue(undefined), }, + env: { + isTelemetryEnabled: true, + }, } }) @@ -197,6 +259,7 @@ vi.mock("../../mentions/resolveImageMentions", () => ({ import { resolveImageMentions } from "../../mentions/resolveImageMentions" import { Terminal } from "../../../integrations/terminal/Terminal" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" +import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { @@ -232,7 +295,10 @@ describe("webviewMessageHandler - requestLmStudioModels", () => { type: "requestLmStudioModels", }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "lmstudio", baseUrl: "http://localhost:1234" }) + expect(mockGetModels).toHaveBeenCalledWith({ + provider: providerIdentifiers.lmstudio, + baseUrl: "http://localhost:1234", + }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "lmStudioModels", @@ -332,7 +398,10 @@ describe("webviewMessageHandler - requestOllamaModels", () => { type: "requestOllamaModels", }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "ollama", baseUrl: "http://localhost:1234" }) + expect(mockGetModels).toHaveBeenCalledWith({ + provider: providerIdentifiers.ollama, + baseUrl: "http://localhost:1234", + }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "ollamaModels", @@ -413,14 +482,14 @@ describe("webviewMessageHandler - requestOllamaModels", () => { // Should use the URL from message values, not the saved state expect(mockFlushModels).toHaveBeenCalledWith( { - provider: "ollama", + provider: providerIdentifiers.ollama, baseUrl: "https://ollama.example.com", apiKey: "secret-key", }, true, ) expect(mockGetModels).toHaveBeenCalledWith({ - provider: "ollama", + provider: providerIdentifiers.ollama, baseUrl: "https://ollama.example.com", apiKey: "secret-key", }) @@ -468,23 +537,27 @@ describe("webviewMessageHandler - requestRouterModels", () => { }) // Verify getModels was called for each provider - expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) + expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.openrouter }) + expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.requesty, apiKey: "requesty-key" }) expect(mockGetModels).toHaveBeenCalledWith( expect.objectContaining({ - provider: "unbound", + provider: providerIdentifiers.unbound, }), ) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) + expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.vercelAiGateway }) expect(mockGetModels).toHaveBeenCalledWith({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "litellm-key", baseUrl: "http://localhost:4000", }) // Opencode Go's /models endpoint is public, so it is fetched like the other no-auth routers. - expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "opencode-go" })) + expect(mockGetModels).toHaveBeenCalledWith( + expect.objectContaining({ provider: providerIdentifiers.opencodeGo }), + ) // Kenari's /models endpoint is public, so it is fetched like the other no-auth routers. - expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "kenari" })) + expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: providerIdentifiers.kenari })) + // NanoGPT's detailed catalog is public and may optionally be scoped by a key. + expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.nanogpt, apiKey: undefined }) // Verify response was sent expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ @@ -503,6 +576,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, @@ -530,7 +604,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { await webviewMessageHandler(mockClineProvider, { type: "requestRouterModels" }) // Must be fetched despite no configured key, forwarding apiKey: undefined. - expect(mockGetModels).toHaveBeenCalledWith({ provider: "opencode-go", apiKey: undefined }) + expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.opencodeGo, apiKey: undefined }) const routerModelsCall = (mockClineProvider.postMessageToWebview as any).mock.calls.find( ([msg]: [{ type: string }]) => msg.type === "routerModels", @@ -554,13 +628,16 @@ describe("webviewMessageHandler - requestRouterModels", () => { await webviewMessageHandler(mockClineProvider, { type: "requestRouterModels", values: { - provider: "opencode-go", + provider: providerIdentifiers.opencodeGo, opencodeGoApiKey: "fresh-key", }, }) - expect(mockFlushModels).toHaveBeenCalledWith({ provider: "opencode-go", apiKey: "fresh-key" }, true) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "opencode-go", apiKey: "fresh-key" }) + expect(mockFlushModels).toHaveBeenCalledWith( + { provider: providerIdentifiers.opencodeGo, apiKey: "fresh-key" }, + true, + ) + expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.opencodeGo, apiKey: "fresh-key" }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "routerModels", routerModels: { @@ -568,7 +645,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "opencode/model": expect.objectContaining({ description: "Opencode model" }), }, }, - values: { provider: "opencode-go" }, + values: { provider: providerIdentifiers.opencodeGo }, }) }) @@ -588,13 +665,16 @@ describe("webviewMessageHandler - requestRouterModels", () => { await webviewMessageHandler(mockClineProvider, { type: "requestRouterModels", values: { - provider: "kenari", + provider: providerIdentifiers.kenari, kenariApiKey: "fresh-kenari-key", }, }) - expect(mockFlushModels).toHaveBeenCalledWith({ provider: "kenari", apiKey: "fresh-kenari-key" }, true) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "kenari", apiKey: "fresh-kenari-key" }) + expect(mockFlushModels).toHaveBeenCalledWith( + { provider: providerIdentifiers.kenari, apiKey: "fresh-kenari-key" }, + true, + ) + expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.kenari, apiKey: "fresh-kenari-key" }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "routerModels", routerModels: { @@ -602,8 +682,61 @@ describe("webviewMessageHandler - requestRouterModels", () => { "glm-5-2": expect.objectContaining({ description: "Kenari model" }), }, }, - values: { provider: "kenari" }, + values: { provider: providerIdentifiers.kenari }, + }) + }) + + it("fetches NanoGPT publicly without an API key", async () => { + mockClineProvider.getState = vi.fn().mockResolvedValue({ apiConfiguration: {} }) + mockGetModels.mockResolvedValue({ + "openai/gpt-5.6-sol": { maxTokens: 128000, contextWindow: 1050000, supportsPromptCache: false }, + }) + + await webviewMessageHandler(mockClineProvider, { + type: "requestRouterModels", + values: { provider: providerIdentifiers.nanogpt }, + }) + + expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.nanogpt, apiKey: undefined }) + expect(mockFlushModels).not.toHaveBeenCalled() + }) + + it("prefers an unsaved NanoGPT key and refreshes the matching key-scoped cache", async () => { + mockClineProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: { nanoGptApiKey: "saved-key" }, + }) + mockGetModels.mockResolvedValue({ + "openai/gpt-5.6-sol": { maxTokens: 128000, contextWindow: 1050000, supportsPromptCache: false }, + }) + + await webviewMessageHandler(mockClineProvider, { + type: "requestRouterModels", + values: { provider: providerIdentifiers.nanogpt, nanoGptApiKey: "unsaved-key" }, + }) + + expect(mockFlushModels).toHaveBeenCalledWith( + { provider: providerIdentifiers.nanogpt, apiKey: "unsaved-key" }, + true, + ) + expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.nanogpt, apiKey: "unsaved-key" }) + }) + + it("uses the saved NanoGPT key for manual refresh", async () => { + mockClineProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: { nanoGptApiKey: "saved-key" }, + }) + mockGetModels.mockResolvedValue({}) + + await webviewMessageHandler(mockClineProvider, { + type: "requestRouterModels", + values: { provider: providerIdentifiers.nanogpt, refresh: true }, }) + + expect(mockFlushModels).toHaveBeenCalledWith( + { provider: providerIdentifiers.nanogpt, apiKey: "saved-key" }, + true, + ) + expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.nanogpt, apiKey: "saved-key" }) }) it("handles LiteLLM models with values from message when config is missing", async () => { @@ -636,7 +769,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { // Verify LiteLLM was called with values from message expect(mockGetModels).toHaveBeenCalledWith({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "message-litellm-key", baseUrl: "http://message-url:4000", }) @@ -670,7 +803,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { // Verify LiteLLM was NOT called expect(mockGetModels).not.toHaveBeenCalledWith( expect.objectContaining({ - provider: "litellm", + provider: providerIdentifiers.litellm, }), ) @@ -691,6 +824,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, @@ -716,6 +850,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { .mockResolvedValueOnce(mockModels) // zoo-gateway .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm .mockResolvedValueOnce(mockModels) // opencode-go + .mockResolvedValueOnce(mockModels) // kenari + .mockResolvedValueOnce(mockModels) // nanogpt await webviewMessageHandler(mockClineProvider, { type: "requestRouterModels", @@ -726,14 +862,14 @@ describe("webviewMessageHandler - requestRouterModels", () => { type: "singleRouterModelFetchResponse", success: false, error: "Requesty API error", - values: { provider: "requesty" }, + values: { provider: providerIdentifiers.requesty }, }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, error: "LiteLLM connection failed", - values: { provider: "litellm" }, + values: { provider: providerIdentifiers.litellm }, }) // Verify final routerModels response includes successful providers and empty objects for failed ones @@ -753,6 +889,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, @@ -778,35 +915,35 @@ describe("webviewMessageHandler - requestRouterModels", () => { type: "singleRouterModelFetchResponse", success: false, error: "Structured error message", - values: { provider: "openrouter" }, + values: { provider: providerIdentifiers.openrouter }, }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, error: "Requesty API error", - values: { provider: "requesty" }, + values: { provider: providerIdentifiers.requesty }, }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, error: "Unbound error", - values: { provider: "unbound" }, + values: { provider: providerIdentifiers.unbound }, }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, error: "Vercel AI Gateway error", - values: { provider: "vercel-ai-gateway" }, + values: { provider: providerIdentifiers.vercelAiGateway }, }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, error: "LiteLLM connection failed", - values: { provider: "litellm" }, + values: { provider: providerIdentifiers.litellm }, }) }) @@ -819,7 +956,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { type: "singleRouterModelFetchResponse", success: false, error: "Roo Code Router has been removed. Please select and configure a different provider.", - values: { provider: "roo" }, + values: { provider: retiredProviderIdentifiers.roo }, }) }) @@ -837,7 +974,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { // Verify message values take precedence over saved config (current unsaved field state wins) expect(mockGetModels).toHaveBeenCalledWith({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "message-key", // From message.values baseUrl: "http://message-url", // From message.values }) @@ -1577,23 +1714,23 @@ describe("zooCodeSignOut", () => { ;(mockClineProvider as any).contextProxy = { ...mockClineProvider.contextProxy, - getProviderSettings: vi.fn().mockReturnValue({ apiProvider: "zoo-gateway" }), + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.zooGateway }), getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), } ;(mockClineProvider as any).providerSettingsManager = { listConfig: vi.fn().mockResolvedValue([ - { name: "Zoo Gateway", apiProvider: "zoo-gateway" }, - { name: "Backup Zoo", apiProvider: "zoo-gateway" }, + { name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }, + { name: "Backup Zoo", apiProvider: providerIdentifiers.zooGateway }, ]), getProfile: vi .fn() .mockResolvedValueOnce({ - apiProvider: "zoo-gateway", + apiProvider: providerIdentifiers.zooGateway, zooSessionToken: "token-active", zooGatewayModelId: "anthropic/claude-sonnet-4", }) .mockResolvedValueOnce({ - apiProvider: "zoo-gateway", + apiProvider: providerIdentifiers.zooGateway, zooSessionToken: "token-backup", }), saveConfig, @@ -1620,13 +1757,15 @@ describe("zooCodeSignOut", () => { ;(mockClineProvider as any).contextProxy = { ...mockClineProvider.contextProxy, - getProviderSettings: vi.fn().mockReturnValue({ apiProvider: "zoo-gateway" }), + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.zooGateway }), getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), } ;(mockClineProvider as any).providerSettingsManager = { - listConfig: vi.fn().mockResolvedValue([{ name: "Zoo Gateway", apiProvider: "zoo-gateway" }]), + listConfig: vi + .fn() + .mockResolvedValue([{ name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }]), getProfile: vi.fn().mockResolvedValue({ - apiProvider: "zoo-gateway", + apiProvider: providerIdentifiers.zooGateway, zooGatewayModelId: "anthropic/claude-sonnet-4", }), saveConfig: vi.fn(), @@ -1787,3 +1926,265 @@ describe("webviewMessageHandler - kimiCodeSignOut", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Kimi Code sign out failed.") }) }) + +describe("webviewMessageHandler - telemetrySetting", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(vscode.env).isTelemetryEnabled = true + }) + + // Regression test: TelemetryService.updateTelemetryState must be gated on + // vscode.env.isTelemetryEnabled in addition to the stored setting, matching + // extension.ts's onDidChangeTelemetryEnabled listener. Without this AND, a user + // clicking Accept in the webview could re-enable telemetry even while VS Code's + // global telemetry toggle is off. + it("does not enable telemetry when the user accepts but VS Code's global telemetry toggle is off", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + vi.mocked(vscode.env).isTelemetryEnabled = false + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(undefined) + + await webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "enabled" }) + + expect(TelemetryService.instance.updateTelemetryState).toHaveBeenCalledWith(false) + }) + + it("enables telemetry when the user accepts and VS Code's global telemetry toggle is on", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + vi.mocked(vscode.env).isTelemetryEnabled = true + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(undefined) + + await webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "enabled" }) + + expect(TelemetryService.instance.updateTelemetryState).toHaveBeenCalledWith(true) + }) + + it("keeps telemetry disabled when the user declines, regardless of VS Code's global toggle", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + vi.mocked(vscode.env).isTelemetryEnabled = true + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(undefined) + + await webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "disabled" }) + + expect(TelemetryService.instance.updateTelemetryState).toHaveBeenCalledWith(false) + }) + + // Finding #12 regression: without serialization, two concurrent "telemetrySetting" messages + // each capture their own isOptedIn in a closure and apply it to TelemetryService whenever + // their own persistence write resolves -- with no ordering guarantee between the two + // invocations. A slow first write racing a fast second write could let the *first* + // message's (now-stale) intent win the live telemetry state, even though the *second* + // message reflects the user's actual final choice. + it("applies the most recently sent telemetrySetting last, even if an earlier message's write is slower", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + vi.mocked(vscode.env).isTelemetryEnabled = true + + // Track the "stored" setting so the second call's getGlobalState read reflects + // whatever the first call has (or hasn't yet) written -- mirrors ContextProxy's real + // synchronous stateCache update inside setValue. + let storedSetting: string | undefined + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation(() => storedSetting) + + let resolveSlowWrite!: () => void + const slowWrite = new Promise((resolve) => { + resolveSlowWrite = resolve + }) + + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (_key, value) => { + if (value === "disabled") { + // First message's write is slow -- resolves only after we explicitly release it + // below, once the second (fast) message has already been sent. + await slowWrite + } + storedSetting = value as string + }) + + // First message: turn telemetry off (slow write). + const first = webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "disabled" }) + + // Second message: turn telemetry back on (fast write), sent immediately after. + const second = webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "enabled" }) + + // Now let the first message's write proceed. + resolveSlowWrite() + + await Promise.all([first, second]) + + // The user's final, most-recently-sent choice was "enabled" -- the live telemetry + // state must reflect that, not "disabled" from the stale, slower first message. + const calls = vi.mocked(TelemetryService.instance.updateTelemetryState).mock.calls + expect(calls.at(-1)).toEqual([true]) + }) + + // CodeRabbit follow-up on the finding #12 fix: webviewDidLaunch's telemetry init read state + // via an async provider.getStateToPostToWebview().then(...) continuation, outside + // telemetrySettingQueue -- so it could resolve after a concurrent "telemetrySetting" message + // and clobber that message's queued (correct) update with a stale value. webviewDidLaunch now + // reads getGlobalState synchronously and is routed through the same queue. + it("does not let webviewDidLaunch's telemetry init race and clobber a concurrent telemetrySetting message", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + vi.mocked(vscode.env).isTelemetryEnabled = true + + // webviewDidLaunch starts out "unset" (disclosed opt-out default -- opted in). Scoped to + // the "telemetrySetting" key specifically -- webviewDidLaunch also calls + // updateGlobalState("customModes", ...) through the same contextProxy mock, which must + // not clobber storedSetting. + let storedSetting: string | undefined = "unset" + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => + key === "telemetrySetting" ? storedSetting : undefined, + ) + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value) => { + if (key === "telemetrySetting") { + storedSetting = value as string + } + }) + + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + const providerForLaunch = mockClineProvider as unknown as { + getMcpHub: ReturnType + providerSettingsManager: { listConfig: ReturnType } + getStateToPostToWebview: ReturnType + } + providerForLaunch.getMcpHub = vi.fn().mockReturnValue(undefined) + providerForLaunch.providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue(undefined), + } + + // Deferred-promise handshake instead of setTimeout delays, so ordering is enforced + // explicitly rather than by racing real clock delays. Signals when webviewDidLaunch has + // taken its (pre-fix) state snapshot -- only fires under the *old* code path + // (provider.getStateToPostToWebview().then(...)); the fix never calls it at all. + let snapshotTaken!: () => void + const snapshotTakenPromise = new Promise((resolve) => { + snapshotTaken = resolve + }) + let releaseSnapshot!: () => void + const snapshotReleased = new Promise((resolve) => { + releaseSnapshot = resolve + }) + + // Snapshots storedSetting at call time (mirroring the real ClineProvider building its + // state object synchronously before any internal awaits), signals it was taken, then + // waits until the test explicitly releases it -- by which point the concurrent + // telemetrySetting write below has already landed, making the snapshot genuinely stale + // once its .then() callback finally runs. + providerForLaunch.getStateToPostToWebview = vi.fn().mockImplementation(async () => { + const snapshot = storedSetting + snapshotTaken() + await snapshotReleased + return { telemetrySetting: snapshot } + }) + + // webviewDidLaunch fires first (e.g. webview reload) -- its telemetry init is now queued + // behind telemetrySettingQueue rather than resolving independently. + const launch = webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" }) + + // Wait for webviewDidLaunch to either take its (pre-fix) snapshot, or flush a fixed + // number of microtask turns as a same-tick fallback for the fixed code path (which never + // triggers that signal) -- enough for its synchronous prefix (await getCustomModes(), + // await updateGlobalState()) to run, without relying on a wall-clock timer. + await Promise.race([ + snapshotTakenPromise, + (async () => { + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } + })(), + ]) + + // A concurrent "telemetrySetting" message turns telemetry off, and is awaited to + // completion -- including its own updateTelemetryState(false) call -- *before* the + // deferred (pre-fix-only) snapshot below is released. Against the pre-fix code, this + // proves the snapshot it captured earlier ("unset") is genuinely stale by the time its + // .then() callback finally runs: the user's real, later choice already landed. + const disable = webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "disabled" }) + await disable + + // Now release the deferred snapshot so a getStateToPostToWebview() call, if the old code + // path is exercised, resolves (with its already-captured, now-stale value) only after + // the disable write above has fully landed. + const snapshotResolved = vi.mocked(providerForLaunch.getStateToPostToWebview).mock.results[0]?.value as + | Promise + | undefined + releaseSnapshot() + + await Promise.all([launch, snapshotResolved]) + + // webviewDidLaunch's telemetry init is fire-and-forget from the handler's own point of + // view (the "webviewDidLaunch" case doesn't await it), so even awaiting + // getStateToPostToWebview() directly isn't enough to observe its .then() callback -- + // flush one more microtask turn for that callback to run. + await Promise.resolve() + + // The user's explicit "disabled" choice must be the final state -- webviewDidLaunch's + // queued re-application of the (by-then-stale) "unset"/opted-in state must not run after + // and override it. + const calls = vi.mocked(TelemetryService.instance.updateTelemetryState).mock.calls + expect(calls.at(-1)).toEqual([false]) + }) + + // Review finding: webviewDidLaunch's queued telemetry update wasn't awaited by the + // "webviewDidLaunch" case, so a thrown error inside it was only ever caught by a later, + // unrelated queue link's leading .catch(() => undefined) -- silently swallowed rather than + // logged. Now awaited with its own .catch that logs via provider.log. + it("logs an error via provider.log if the queued telemetry init throws on launch", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => { + if (key === "telemetrySetting") { + throw new Error("contextProxy read failed") + } + return undefined + }) + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + const providerForLaunch = mockClineProvider as unknown as { + getMcpHub: ReturnType + providerSettingsManager: { listConfig: ReturnType } + getStateToPostToWebview: ReturnType + } + providerForLaunch.getMcpHub = vi.fn().mockReturnValue(undefined) + providerForLaunch.providerSettingsManager = { listConfig: vi.fn().mockResolvedValue(undefined) } + providerForLaunch.getStateToPostToWebview = vi.fn().mockResolvedValue({ telemetrySetting: "unset" }) + + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" }) + + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Error initializing telemetry state on launch"), + ) + }) + + // CodeRabbit finding: webviewDidLaunch's queued telemetry update called + // TelemetryService.instance directly, unlike the "telemetrySetting" case a few lines + // below which checks hasInstance() first. If webviewDidLaunch fires before the service + // is created (e.g. during activation), TelemetryService.instance throws -- and since + // this whole chain isn't awaited by the "webviewDidLaunch" case, that throw becomes an + // unhandled promise rejection instead of a no-op. + it("does not throw or update telemetry state when webviewDidLaunch fires before TelemetryService exists", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(false) + + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue("unset") + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + const providerForLaunch = mockClineProvider as unknown as { + getMcpHub: ReturnType + providerSettingsManager: { listConfig: ReturnType } + getStateToPostToWebview: ReturnType + } + providerForLaunch.getMcpHub = vi.fn().mockReturnValue(undefined) + providerForLaunch.providerSettingsManager = { listConfig: vi.fn().mockResolvedValue(undefined) } + providerForLaunch.getStateToPostToWebview = vi.fn().mockResolvedValue({ telemetrySetting: "unset" }) + + await expect(webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" })).resolves.not.toThrow() + + // The queued telemetry update is fire-and-forget from the handler's own point of + // view -- flush a microtask turn so its .then() callback runs before asserting. + await Promise.resolve() + + expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5a28ce12d0..cd817074d6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -23,6 +23,13 @@ import { checkoutRestorePayloadSchema, getCompletionCheckpoint, providerIdentifiers, + retiredProviderIdentifiers, + LmStudioModelsMessageType, + OllamaModelsMessageType, + OpenAiModelsMessageType, + RouterModelsMessageType, + VsCodeLmModelsMessageType, + isTelemetryOptedIn, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -87,6 +94,15 @@ import { getLMStudioModels } from "../../api/providers/fetchers/lmstudio" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) +// Serializes handling of "telemetrySetting" messages. Each invocation reads the previous +// setting, awaits a persistence write, then applies the new live telemetry state -- with no +// serialization, two rapid messages (e.g. a fast toggle) can interleave across those awaits: +// each invocation captures its own isOptedIn in a closure, so whichever invocation's tail end +// (TelemetryService.instance.updateTelemetryState) happens to resolve *last* wins, regardless +// of which message the user sent last. Chaining onto this promise ensures a given invocation's +// entire read-write-apply sequence completes before the next one starts. +let telemetrySettingQueue: Promise = Promise.resolve() + import { MarketplaceManager, MarketplaceItemType } from "../../services/marketplace" import { setPendingTodoList } from "../tools/UpdateTodoListTool" import { @@ -558,6 +574,11 @@ export const webviewMessageHandler = async ( } switch (message.type) { + case "themeFixtureProbeResponse": + if (process.env.ROO_CODE_THEME_FIXTURE_PROBE === "1" && message.requestId && message.themeFixture) { + provider.resolveWebviewThemeFixtureProbe(message.requestId, message.themeFixture) + } + break case "webviewDidLaunch": // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() @@ -633,12 +654,37 @@ export const webviewMessageHandler = async ( ), ) - // Enable telemetry by default (when unset) or when explicitly enabled - await provider.getStateToPostToWebview().then((state) => { - const { telemetrySetting } = state - const isOptedIn = telemetrySetting !== "disabled" - TelemetryService.instance.updateTelemetryState(isOptedIn) - }) + // Telemetry is on by disclosed default: "unset" (no choice made yet) leaves that + // default in effect, same as "enabled". Only an explicit "disabled" opts out. + // vscode.env.isTelemetryEnabled is ANDed in (matching extension.ts's + // onDidChangeTelemetryEnabled listener) so a webview reload can't re-enable + // telemetry while VS Code's global toggle is off. + // + // Read the setting synchronously via getGlobalState (same as the "telemetrySetting" + // handler below) rather than awaiting provider.getStateToPostToWebview() -- that + // async gap let this continuation resolve after a concurrent "telemetrySetting" + // message's queued update and clobber it with a stale value, the same interleaving + // class of bug telemetrySettingQueue exists to prevent. Routing through the queue + // here too means webviewDidLaunch can't race a concurrent telemetrySetting message + // either. + telemetrySettingQueue = telemetrySettingQueue + .catch(() => undefined) + .then(async () => { + if (!TelemetryService.hasInstance()) { + return + } + + const telemetrySetting = getGlobalState("telemetrySetting") || "unset" + TelemetryService.instance.updateTelemetryState( + isTelemetryOptedIn(telemetrySetting) && vscode.env.isTelemetryEnabled, + ) + }) + + await telemetrySettingQueue.catch((error) => + provider.log( + `Error initializing telemetry state on launch: ${error instanceof Error ? error.message : String(error)}`, + ), + ) provider.isViewLaunched = true break @@ -997,7 +1043,6 @@ export const webviewMessageHandler = async ( // so a retry after a partial-copy failure still reconciles the store. await provider.taskHistoryStore.invalidateAll() await provider.taskHistoryStore.reconcile() - await provider.taskHistoryStore.flushIndex() await provider.postStateToWebview() await provider.postMessageToWebview({ type: "rooHistoryImportProgress", @@ -1044,13 +1089,13 @@ export const webviewMessageHandler = async ( case "resetState": await provider.resetState() break - case "flushRouterModels": + case RouterModelsMessageType.flushRouterModels: const routerNameFlush: RouterName = toRouterName(message.text) // Note: flushRouterModels is a generic flush without credentials // For providers that need credentials, use their specific handlers await flushModels({ provider: routerNameFlush } as GetModelsOptions, true) break - case "requestRouterModels": { + case RouterModelsMessageType.requestRouterModels: { const { apiConfiguration } = await provider.getState() // Optional single provider filter from webview @@ -1063,20 +1108,21 @@ export const webviewMessageHandler = async ( const routerModels: Record = providerFilter ? ({} as Record) : { - openrouter: {}, - "vercel-ai-gateway": {}, - "zoo-gateway": {}, - litellm: {}, - requesty: {}, - unbound: {}, - ollama: {}, - lmstudio: {}, - poe: {}, - deepseek: {}, - moonshot: {}, - "opencode-go": {}, - kenari: {}, - "kimi-code": {}, + [providerIdentifiers.openrouter]: {}, + [providerIdentifiers.vercelAiGateway]: {}, + [providerIdentifiers.zooGateway]: {}, + [providerIdentifiers.litellm]: {}, + [providerIdentifiers.requesty]: {}, + [providerIdentifiers.unbound]: {}, + [providerIdentifiers.ollama]: {}, + [providerIdentifiers.lmstudio]: {}, + [providerIdentifiers.poe]: {}, + [providerIdentifiers.deepseek]: {}, + [providerIdentifiers.moonshot]: {}, + [providerIdentifiers.opencodeGo]: {}, + [providerIdentifiers.kenari]: {}, + [providerIdentifiers.nanogpt]: {}, + [providerIdentifiers.kimiCode]: {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1094,27 +1140,33 @@ export const webviewMessageHandler = async ( // Base candidates (only those handled by this aggregate fetcher) const candidates: { key: RouterName; options: GetModelsOptions }[] = [ - { key: "openrouter", options: { provider: "openrouter" } }, { - key: "requesty", + key: providerIdentifiers.openrouter, + options: { provider: providerIdentifiers.openrouter }, + }, + { + key: providerIdentifiers.requesty, options: { - provider: "requesty", + provider: providerIdentifiers.requesty, apiKey: apiConfiguration.requestyApiKey, baseUrl: apiConfiguration.requestyBaseUrl, }, }, { - key: "unbound", + key: providerIdentifiers.unbound, options: { - provider: "unbound", + provider: providerIdentifiers.unbound, apiKey: apiConfiguration.unboundApiKey, }, }, - { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, { - key: "zoo-gateway", + key: providerIdentifiers.vercelAiGateway, + options: { provider: providerIdentifiers.vercelAiGateway }, + }, + { + key: providerIdentifiers.zooGateway, options: { - provider: "zoo-gateway", + provider: providerIdentifiers.zooGateway, apiKey: apiConfiguration.zooSessionToken, baseUrl: apiConfiguration.zooGatewayBaseUrl, }, @@ -1131,12 +1183,15 @@ export const webviewMessageHandler = async ( // If explicit credentials are provided in message.values (from Refresh Models button), // flush the cache first to ensure we fetch fresh data with the new credentials if (message?.values?.litellmApiKey || message?.values?.litellmBaseUrl) { - await flushModels({ provider: "litellm", apiKey: litellmApiKey, baseUrl: litellmBaseUrl }, true) + await flushModels( + { provider: providerIdentifiers.litellm, apiKey: litellmApiKey, baseUrl: litellmBaseUrl }, + true, + ) } candidates.push({ - key: "litellm", - options: { provider: "litellm", apiKey: litellmApiKey, baseUrl: litellmBaseUrl }, + key: providerIdentifiers.litellm, + options: { provider: providerIdentifiers.litellm, apiKey: litellmApiKey, baseUrl: litellmBaseUrl }, }) } @@ -1146,12 +1201,15 @@ export const webviewMessageHandler = async ( if (poeApiKey) { if (message?.values?.poeApiKey || message?.values?.poeBaseUrl) { - await flushModels({ provider: "poe", apiKey: poeApiKey, baseUrl: poeBaseUrl }, true) + await flushModels( + { provider: providerIdentifiers.poe, apiKey: poeApiKey, baseUrl: poeBaseUrl }, + true, + ) } candidates.push({ - key: "poe", - options: { provider: "poe", apiKey: poeApiKey, baseUrl: poeBaseUrl }, + key: providerIdentifiers.poe, + options: { provider: providerIdentifiers.poe, apiKey: poeApiKey, baseUrl: poeBaseUrl }, }) } @@ -1161,12 +1219,19 @@ export const webviewMessageHandler = async ( if (deepSeekApiKey) { if (message?.values?.deepSeekApiKey || message?.values?.deepSeekBaseUrl) { - await flushModels({ provider: "deepseek", apiKey: deepSeekApiKey, baseUrl: deepSeekBaseUrl }, true) + await flushModels( + { provider: providerIdentifiers.deepseek, apiKey: deepSeekApiKey, baseUrl: deepSeekBaseUrl }, + true, + ) } candidates.push({ - key: "deepseek", - options: { provider: "deepseek", apiKey: deepSeekApiKey, baseUrl: deepSeekBaseUrl }, + key: providerIdentifiers.deepseek, + options: { + provider: providerIdentifiers.deepseek, + apiKey: deepSeekApiKey, + baseUrl: deepSeekBaseUrl, + }, }) } @@ -1176,12 +1241,19 @@ export const webviewMessageHandler = async ( if (moonshotApiKey) { if (message?.values?.moonshotApiKey || message?.values?.moonshotBaseUrl) { - await flushModels({ provider: "moonshot", apiKey: moonshotApiKey, baseUrl: moonshotBaseUrl }, true) + await flushModels( + { provider: providerIdentifiers.moonshot, apiKey: moonshotApiKey, baseUrl: moonshotBaseUrl }, + true, + ) } candidates.push({ - key: "moonshot", - options: { provider: "moonshot", apiKey: moonshotApiKey, baseUrl: moonshotBaseUrl }, + key: providerIdentifiers.moonshot, + options: { + provider: providerIdentifiers.moonshot, + apiKey: moonshotApiKey, + baseUrl: moonshotBaseUrl, + }, }) } @@ -1194,12 +1266,12 @@ export const webviewMessageHandler = async ( // Refresh the cache when a new key is explicitly provided (e.g. the Refresh Models button). if (message?.values?.opencodeGoApiKey) { - await flushModels({ provider: "opencode-go", apiKey: opencodeGoApiKey }, true) + await flushModels({ provider: providerIdentifiers.opencodeGo, apiKey: opencodeGoApiKey }, true) } candidates.push({ - key: "opencode-go", - options: { provider: "opencode-go", apiKey: opencodeGoApiKey }, + key: providerIdentifiers.opencodeGo, + options: { provider: providerIdentifiers.opencodeGo, apiKey: opencodeGoApiKey }, }) // Kenari's /models endpoint is public — it returns the full model list with no @@ -1211,15 +1283,28 @@ export const webviewMessageHandler = async ( // Refresh the cache when a new key is explicitly provided (e.g. the Refresh Models button). if (message?.values?.kenariApiKey) { - await flushModels({ provider: "kenari", apiKey: kenariApiKey }, true) + await flushModels({ provider: providerIdentifiers.kenari, apiKey: kenariApiKey }, true) + } + + candidates.push({ + key: providerIdentifiers.kenari, + options: { provider: providerIdentifiers.kenari, apiKey: kenariApiKey }, + }) + + // NanoGPT's detailed catalog is public, while an optional key can expose a + // different allowlist. Prefer an explicitly supplied unsaved key and use the + // same key-scoped options for refresh and retrieval. + const nanoGptApiKey = message?.values?.nanoGptApiKey ?? apiConfiguration.nanoGptApiKey + if (message?.values?.nanoGptApiKey !== undefined) { + await flushModels({ provider: providerIdentifiers.nanogpt, apiKey: nanoGptApiKey }, true) } candidates.push({ - key: "kenari", - options: { provider: "kenari", apiKey: kenariApiKey }, + key: providerIdentifiers.nanogpt, + options: { provider: providerIdentifiers.nanogpt, apiKey: nanoGptApiKey }, }) - if (!providerFilter || providerFilter === "kimi-code") { + if (!providerFilter || providerFilter === providerIdentifiers.kimiCode) { const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") const kimiCodeAuthMethod = message?.values?.kimiCodeAuthMethod ?? apiConfiguration.kimiCodeAuthMethod ?? "oauth" @@ -1229,8 +1314,8 @@ export const webviewMessageHandler = async ( : await kimiCodeOAuthManager.getAccessToken() if (kimiCodeApiKey) { candidates.push({ - key: "kimi-code", - options: { provider: "kimi-code", apiKey: kimiCodeApiKey }, + key: providerIdentifiers.kimiCode, + options: { provider: providerIdentifiers.kimiCode, apiKey: kimiCodeApiKey }, }) } } @@ -1268,7 +1353,7 @@ export const webviewMessageHandler = async ( routerModels[routerName] = {} // Ensure it's an empty object in the main routerModels message. void provider.postMessageToWebview({ - type: "singleRouterModelFetchResponse", + type: RouterModelsMessageType.singleRouterModelFetchResponse, success: false, error: errorMessage, values: { provider: routerName }, @@ -1277,13 +1362,13 @@ export const webviewMessageHandler = async ( }) await provider.postMessageToWebview({ - type: "routerModels", + type: RouterModelsMessageType.routerModels, routerModels, values: providerFilter ? { provider: requestedProvider } : undefined, }) break } - case "requestOllamaModels": { + case OllamaModelsMessageType.requestOllamaModels: { // Specific handler for Ollama models only. const { apiConfiguration: ollamaApiConfig } = await provider.getState() // Prefer the baseUrl/apiKey from the message values (which reflect @@ -1294,7 +1379,7 @@ export const webviewMessageHandler = async ( const apiKey = message.values?.apiKey ?? ollamaApiConfig.ollamaApiKey const logBaseUrl = baseUrl || "http://localhost:11434" const ollamaOptions = { - provider: "ollama" as const, + provider: providerIdentifiers.ollama, baseUrl, apiKey, } @@ -1307,7 +1392,7 @@ export const webviewMessageHandler = async ( const errorMsg = error instanceof Error ? error.message : String(error) provider.log(`[requestOllamaModels] Failed to refresh model cache for ${logBaseUrl}: ${errorMsg}`) await provider.postMessageToWebview({ - type: "ollamaModels", + type: OllamaModelsMessageType.ollamaModels, ollamaModels: {}, error: errorMsg, }) @@ -1319,19 +1404,19 @@ export const webviewMessageHandler = async ( // Always post a response so the webview refresh status can // transition out of "loading" — even when no models are found. - await provider.postMessageToWebview({ type: "ollamaModels", ollamaModels }) + await provider.postMessageToWebview({ type: OllamaModelsMessageType.ollamaModels, ollamaModels }) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) provider.log(`[requestOllamaModels] Failed to read models for ${logBaseUrl}: ${errorMsg}`) await provider.postMessageToWebview({ - type: "ollamaModels", + type: OllamaModelsMessageType.ollamaModels, ollamaModels: {}, error: errorMsg, }) } break } - case "requestLmStudioModels": { + case LmStudioModelsMessageType.requestLmStudioModels: { // Specific handler for LM Studio models only. const { apiConfiguration: lmStudioApiConfig } = await provider.getState() try { @@ -1342,7 +1427,7 @@ export const webviewMessageHandler = async ( lmStudioModels = await getLMStudioModels(requestedBaseUrl) } else { const lmStudioOptions = { - provider: "lmstudio" as const, + provider: providerIdentifiers.lmstudio, baseUrl: lmStudioApiConfig.lmStudioBaseUrl, } // Flush cache and refresh to ensure fresh models. @@ -1352,7 +1437,7 @@ export const webviewMessageHandler = async ( if (Object.keys(lmStudioModels).length > 0) { await provider.postMessageToWebview({ - type: "lmStudioModels", + type: LmStudioModelsMessageType.lmStudioModels, lmStudioModels: lmStudioModels, }) } @@ -1364,14 +1449,14 @@ export const webviewMessageHandler = async ( } case "requestRooModels": { await provider.postMessageToWebview({ - type: "singleRouterModelFetchResponse", + type: RouterModelsMessageType.singleRouterModelFetchResponse, success: false, error: getRouterRemovalMessage(), - values: { provider: "roo" }, + values: { provider: retiredProviderIdentifiers.roo }, }) break } - case "requestOpenAiModels": + case OpenAiModelsMessageType.requestOpenAiModels: if (message?.values?.baseUrl && message?.values?.apiKey) { const openAiModels = await getOpenAiModels( message?.values?.baseUrl, @@ -1379,14 +1464,14 @@ export const webviewMessageHandler = async ( message?.values?.openAiHeaders, ) - await provider.postMessageToWebview({ type: "openAiModels", openAiModels }) + await provider.postMessageToWebview({ type: OpenAiModelsMessageType.openAiModels, openAiModels }) } break - case "requestVsCodeLmModels": + case VsCodeLmModelsMessageType.requestVsCodeLmModels: const vsCodeLmModels = await getVsCodeLmModels() // TODO: Cache like we do for OpenRouter, etc? - await provider.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels }) + await provider.postMessageToWebview({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels }) break case "openImage": await openImage(message.text!, { values: message.values }) @@ -2567,29 +2652,46 @@ export const webviewMessageHandler = async ( } break case "telemetrySetting": { - const telemetrySetting = message.text as TelemetrySetting - const previousSetting = getGlobalState("telemetrySetting") || "unset" - const isOptedIn = telemetrySetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + // Chain onto the shared queue so a concurrent "telemetrySetting" message (e.g. a + // rapid toggle) can't interleave its read-write-apply sequence with this one -- see + // the telemetrySettingQueue comment above for why that matters. Swallow a prior + // link's rejection before chaining (rather than letting .then() propagate it) so one + // failed update can't permanently poison every subsequent telemetrySetting message + // for the rest of the session. + const thisUpdate = telemetrySettingQueue + .catch(() => undefined) + .then(async () => { + const telemetrySetting = message.text as TelemetrySetting + const previousSetting = getGlobalState("telemetrySetting") || "unset" + const isOptedIn = isTelemetryOptedIn(telemetrySetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) + + // If turning telemetry OFF, fire event BEFORE disabling + if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { + TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, telemetrySetting) + } - // If turning telemetry OFF, fire event BEFORE disabling - if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { - TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, telemetrySetting) - } + // Update the telemetry state. vscode.env.isTelemetryEnabled is ANDed in + // (matching extension.ts's onDidChangeTelemetryEnabled listener) so this can't + // re-enable telemetry while VS Code's global toggle is off -- the + // captureTelemetrySettingsChanged calls above/below still track the user's + // stored preference transition on its own, independent of that live toggle. + await updateGlobalState("telemetrySetting", telemetrySetting) - // Update the telemetry state - await updateGlobalState("telemetrySetting", telemetrySetting) + if (TelemetryService.hasInstance()) { + TelemetryService.instance.updateTelemetryState(isOptedIn && vscode.env.isTelemetryEnabled) + } - if (TelemetryService.hasInstance()) { - TelemetryService.instance.updateTelemetryState(isOptedIn) - } + // If turning telemetry ON, fire event AFTER enabling + if (!wasPreviouslyOptedIn && isOptedIn && TelemetryService.hasInstance()) { + TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, telemetrySetting) + } - // If turning telemetry ON, fire event AFTER enabling - if (!wasPreviouslyOptedIn && isOptedIn && TelemetryService.hasInstance()) { - TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, telemetrySetting) - } + await provider.postStateToWebview() + }) + telemetrySettingQueue = thisUpdate - await provider.postStateToWebview() + await thisUpdate break } case "debugSetting": { diff --git a/src/eslint-rules/no-raw-provider-identifiers.mjs b/src/eslint-rules/no-raw-provider-identifiers.mjs new file mode 100644 index 0000000000..7520cc6414 --- /dev/null +++ b/src/eslint-rules/no-raw-provider-identifiers.mjs @@ -0,0 +1,122 @@ +import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" + +const providerReplacementsByValue = new Map([ + ...Object.entries(providerIdentifiers).map(([member, value]) => [value, `providerIdentifiers.${member}`]), + ...Object.entries(retiredProviderIdentifiers).map(([member, value]) => [ + value, + `retiredProviderIdentifiers.${member}`, + ]), +]) +const typescriptExpressionWrappers = new Set([ + "TSAsExpression", + "TSNonNullExpression", + "TSSatisfiesExpression", + "TSTypeAssertion", +]) + +function getStaticName(node) { + if (node?.type === "Identifier") { + return node.name + } + + if (node?.type === "MemberExpression") { + if (!node.computed && node.property.type === "Identifier") { + return node.property.name + } + + if (node.computed && node.property.type === "Literal" && typeof node.property.value === "string") { + return node.property.value + } + } + + if (node?.type === "Literal" && typeof node.value === "string") { + return node.value + } + + return undefined +} + +function isProviderLike(node) { + return getStaticName(node)?.toLowerCase().includes("provider") ?? false +} + +function getRawProvider(node) { + while (typescriptExpressionWrappers.has(node?.type)) { + node = node.expression + } + + if (node?.type === "Literal" && typeof node.value === "string") { + const replacement = providerReplacementsByValue.get(node.value) + return replacement ? { replacement, value: node.value } : undefined + } + + if (node?.type === "TemplateLiteral" && node.expressions.length === 0) { + const value = node.quasis[0]?.value.cooked + const replacement = value ? providerReplacementsByValue.get(value) : undefined + return replacement ? { replacement, value } : undefined + } + + return undefined +} + +export const noRawProviderIdentifiers = { + meta: { + type: "problem", + docs: { description: "Require canonical provider identifiers in provider-like contexts" }, + schema: [], + messages: { + useCanonical: + 'Use {{replacement}} instead of the raw provider identifier "{{value}}".', + }, + }, + create(context) { + function reportIfRawProvider(node) { + const provider = getRawProvider(node) + if (provider) { + context.report({ node, messageId: "useCanonical", data: provider }) + } + } + + return { + Property(node) { + if (isProviderLike(node.key)) { + reportIfRawProvider(node.value) + } + }, + PropertyDefinition(node) { + if (isProviderLike(node.key)) { + reportIfRawProvider(node.value) + } + }, + VariableDeclarator(node) { + if (isProviderLike(node.id)) { + reportIfRawProvider(node.init) + } + }, + AssignmentExpression(node) { + if (isProviderLike(node.left)) { + reportIfRawProvider(node.right) + } + }, + BinaryExpression(node) { + if (!["===", "!==", "==", "!="].includes(node.operator)) { + return + } + + if (isProviderLike(node.left)) { + reportIfRawProvider(node.right) + } + if (isProviderLike(node.right)) { + reportIfRawProvider(node.left) + } + }, + SwitchStatement(node) { + if (isProviderLike(node.discriminant)) { + for (const switchCase of node.cases) { + reportIfRawProvider(switchCase.test) + } + } + }, + } + }, +} diff --git a/src/eslint-rules/no-raw-provider-identifiers.test.mjs b/src/eslint-rules/no-raw-provider-identifiers.test.mjs new file mode 100644 index 0000000000..c4efaf90ff --- /dev/null +++ b/src/eslint-rules/no-raw-provider-identifiers.test.mjs @@ -0,0 +1,39 @@ +import { RuleTester } from "eslint" + +import { noRawProviderIdentifiers } from "./no-raw-provider-identifiers.mjs" + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, +}) + +ruleTester.run("no-raw-provider-identifiers", noRawProviderIdentifiers, { + valid: [ + "const apiProvider = retiredProviderIdentifiers.roo", + "const provider = retiredProviderIdentifiers.groq", + ], + invalid: [ + { + code: 'const apiProvider = "roo"', + errors: [ + { + message: + 'Use retiredProviderIdentifiers.roo instead of the raw provider identifier "roo".', + type: "Literal", + }, + ], + }, + { + code: "const persistedProvider = `groq`", + errors: [ + { + message: + 'Use retiredProviderIdentifiers.groq instead of the raw provider identifier "groq".', + type: "TemplateLiteral", + }, + ], + }, + ], +}) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 569c846c29..6903a77ab0 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -24,11 +24,6 @@ "count": 6 } }, - "__tests__/extension.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "__tests__/history-resume-delegation.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 72 @@ -779,16 +774,6 @@ "count": 4 } }, - "core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, "core/task-persistence/__tests__/TaskHistoryStore.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 4 @@ -1711,7 +1696,7 @@ }, "utils/__tests__/shell.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 46 + "count": 35 } }, "utils/__tests__/storage.spec.ts": { diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index 65965eb8d5..903445b7f5 100644 --- a/src/eslint.config.mjs +++ b/src/eslint.config.mjs @@ -1,5 +1,7 @@ import { config } from "@roo-code/config-eslint/base" +import { noRawProviderIdentifiers } from "./eslint-rules/no-raw-provider-identifiers.mjs" + /** @type {import("eslint").Linter.Config} */ export default [ ...config, @@ -31,10 +33,26 @@ export default [ "no-undef": "off", }, }, + { + files: ["**/*.ts", "**/*.tsx"], + ignores: [ + "**/fixtures/**", + ], + plugins: { + zoo: { + rules: { + "no-raw-provider-identifiers": noRawProviderIdentifiers, + }, + }, + }, + rules: { + "zoo/no-raw-provider-identifiers": "error", + }, + }, { // Ratchet: enforce no-floating-promises directory by directory. Each // directory is added here once its floating promises are resolved. - files: ["activate/**/*.ts", "core/task/**/*.ts", "core/webview/**/*.ts"], + files: ["activate/**/*.ts", "core/task/**/*.ts", "core/tools/**/*.ts", "core/webview/**/*.ts"], languageOptions: { parserOptions: { project: true, diff --git a/src/extension.ts b/src/extension.ts index b880bee410..e618874f1a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,6 +16,7 @@ if (fs.existsSync(envPath)) { } import type { CloudUserInfo, AuthState } from "@roo-code/types" +import { isTelemetryOptedIn } from "@roo-code/types" import { CloudService } from "@roo-code/cloud" import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry" import { customToolRegistry } from "@roo-code/core" @@ -173,6 +174,29 @@ export async function activate(context: vscode.ExtensionContext) { } const contextProxy = await ContextProxy.getInstance(context) + const updateTelemetryState = () => { + const telemetrySetting = contextProxy.getGlobalState("telemetrySetting") ?? "unset" + TelemetryService.instance.updateTelemetryState( + isTelemetryOptedIn(telemetrySetting) && vscode.env.isTelemetryEnabled, + ) + } + + updateTelemetryState() + + // React live to VS Code's global telemetry toggle (recommended over only reading + // telemetry.telemetryLevel, which PostHogTelemetryClient still checks as a secondary gate). + // vscode.env.isTelemetryEnabled is ANDed in directly because the deprecated + // telemetry.telemetryLevel setting the client checks doesn't reflect this live event. + context.subscriptions.push( + vscode.env.onDidChangeTelemetryEnabled(() => { + updateTelemetryState() + + // Push the new vscode.env.isTelemetryEnabled value to the webview too, so its + // own PostHog client (gated separately in TelemetryClient.ts) can't keep + // sending events after the global toggle flips off mid-session. + void ClineProvider.getVisibleInstance()?.postStateToWebviewWithoutClineMessages() + }), + ) // Initialize code index managers for all workspace folders. const codeIndexManagers: CodeIndexManager[] = [] @@ -389,12 +413,14 @@ export async function deactivate() { await McpServerManager.cleanup(extensionContext) - try { - await TelemetryService.instance.shutdown() - } catch (error) { - outputChannel.appendLine( - `Failed to shut down telemetry service: ${error instanceof Error ? error.message : String(error)}`, - ) + if (TelemetryService.hasInstance()) { + try { + await TelemetryService.instance.shutdown() + } catch (error) { + outputChannel.appendLine( + `Failed to shut down telemetry service: ${error instanceof Error ? error.message : String(error)}`, + ) + } } Terminal.setTerminalProfile(undefined) diff --git a/src/extension/__tests__/api-theme-fixture.spec.ts b/src/extension/__tests__/api-theme-fixture.spec.ts new file mode 100644 index 0000000000..6f7a378882 --- /dev/null +++ b/src/extension/__tests__/api-theme-fixture.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest" +import type * as vscode from "vscode" + +import { API } from "../api" +import type { ClineProvider } from "../../core/webview/ClineProvider" + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class {}, +})) + +describe("API - theme fixture probe", () => { + it("delegates capture to the sidebar provider", async () => { + const fixture = { + themeId: "Default Dark Modern", + bodyClass: "vscode-dark", + variables: { "--vscode-foreground": "#cccccc" }, + } + const requestWebviewThemeFixture = vi.fn().mockResolvedValue(fixture) + const provider = { + context: {}, + on: vi.fn(), + requestWebviewThemeFixture, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider) + + await expect(api.captureWebviewThemeFixture()).resolves.toEqual(fixture) + expect(requestWebviewThemeFixture).toHaveBeenCalledOnce() + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index b57dc89b74..16f1d402bc 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -14,6 +14,7 @@ import { type ProviderSettingsEntry, type TaskEvent, type CreateTaskOptions, + type WebviewThemeFixture, RooCodeEventName, TaskCommandName, isSecretStateKey, @@ -314,6 +315,10 @@ export class API extends EventEmitter implements RooCodeAPI { return this.sidebarProvider.viewLaunched } + public captureWebviewThemeFixture(): Promise { + return this.sidebarProvider.requestWebviewThemeFixture() + } + private async waitForWebviewLaunch(timeoutMs: number): Promise { try { await pWaitFor(() => this.sidebarProvider.viewLaunched, { diff --git a/src/integrations/terminal/__tests__/shell-system-prompt-divergence.spec.ts b/src/integrations/terminal/__tests__/shell-system-prompt-divergence.spec.ts new file mode 100644 index 0000000000..183a98d492 --- /dev/null +++ b/src/integrations/terminal/__tests__/shell-system-prompt-divergence.spec.ts @@ -0,0 +1,167 @@ +// Regression test for https://github.com/Zoo-Code-Org/Zoo-Code/issues/634 +// +// Root cause: getShell() (system prompt) used config.get() which merges all scopes +// including workspace, while Terminal.getConfiguredDefaultProfileName() used +// inspect().globalValue — intentionally excluding workspace scope for security. +// terminal.integrated.defaultProfile.* is APPLICATION-scoped; workspace values are +// technically accepted by VS Code but ignored by the terminal itself. +// +// Fix: getShell() now delegates to Terminal.getConfiguredDefaultProfileName() and +// Terminal.getConfiguredProfiles(), so both paths read the same inspect()-based values +// and can never disagree. +// +// Run: node_modules/.bin/vitest run integrations/terminal/__tests__/shell-system-prompt-divergence.spec.ts + +import { existsSync } from "fs" +import * as vscode from "vscode" + +vi.mock("execa", () => ({ execa: vi.fn() })) +vi.mock("fs", () => ({ existsSync: vi.fn(() => false) })) +vi.mock("os", () => ({ userInfo: vi.fn(() => ({ shell: null })) })) + +const mockedExistsSync = existsSync as unknown as ReturnType + +const { Terminal } = await import("../Terminal") +const { getShell } = await import("../../../utils/shell") + +describe("issue #634 — system prompt shell vs actual terminal shell divergence", () => { + let originalPlatform: NodeJS.Platform + + beforeEach(() => { + originalPlatform = process.platform + Object.defineProperty(process, "platform", { value: "win32", configurable: true }) + Terminal.setTerminalProfile(undefined) + mockedExistsSync.mockReset() + // pwsh.exe exists — getShell() fallback path prefers PowerShell 7 over legacy + mockedExistsSync.mockImplementation((p: string) => p === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") + }) + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }) + Terminal.setTerminalProfile(undefined) + vi.restoreAllMocks() + }) + + /** + * Stubs VS Code config to simulate a workspace-scoped default profile. + * globalValue is undefined for both the profile name and profiles map, + * so Terminal (which reads only globalValue ?? defaultValue) sees no profile. + * The workspace-scoped value is present to verify it is correctly ignored. + */ + function stubWorkspaceScopedProfile(profileName: string, profilePath: string) { + const profiles = { [profileName]: { path: profilePath } } + vi.spyOn(vscode.workspace, "getConfiguration").mockImplementation((section?: string) => { + if (section === "terminal.integrated") { + return { + // get() merges all scopes — shell.ts uses this, picks up workspace value + get: (key: string) => { + if (key === "defaultProfile.windows") return profileName + if (key === "profiles.windows") return profiles + return undefined + }, + // Terminal uses inspect() and only reads globalValue ?? defaultValue + inspect: (_key: string) => ({ + defaultValue: undefined, + globalValue: undefined, + workspaceValue: profileName, + }), + } as unknown as vscode.WorkspaceConfiguration + } + + if (section === "terminal.integrated.profiles") { + return { + inspect: (_key: string) => ({ + defaultValue: undefined, + globalValue: undefined, + workspaceValue: profiles, + }), + } as unknown as vscode.WorkspaceConfiguration + } + + return { + get: (_key: string, dv?: unknown) => dv, + inspect: () => undefined, + } as unknown as vscode.WorkspaceConfiguration + }) + } + + it("Terminal.getConfiguredDefaultProfileName ignores workspace-scoped profile (confirms the bug)", () => { + // User set PowerShell as default only in their workspace .vscode/settings.json + stubWorkspaceScopedProfile("PowerShell", "C:\\Program Files\\PowerShell\\7\\pwsh.exe") + + // Terminal intentionally excludes workspace scope for security. + // With no global/default profile set, it returns undefined. + const terminalSeesProfileName = Terminal.getConfiguredDefaultProfileName("win32") + expect(terminalSeesProfileName).toBeUndefined() + + // As a consequence, isActiveShellPowerShell returns false even though the + // user configured PowerShell — the terminal will not be treated as PowerShell. + expect(Terminal.isActiveShellPowerShell("win32")).toBe(false) + }) + + it("getShell() and Terminal agree on PowerShell when the default profile is set at global/user scope", () => { + // When the profile is set at user (global) scope, both paths see the same value. + const profilePath = "C:\\Program Files\\Git\\bin\\bash.exe" // non-PowerShell so name-matching doesn't hide the bug + const profileName = "Git Bash" + vi.spyOn(vscode.workspace, "getConfiguration").mockImplementation((section?: string) => { + if (section === "terminal.integrated") { + return { + get: (key: string) => { + if (key === "defaultProfile.windows") return profileName + if (key === "profiles.windows") return { [profileName]: { path: profilePath } } + return undefined + }, + inspect: (_key: string) => ({ + defaultValue: undefined, + globalValue: profileName, + workspaceValue: undefined, + }), + } as unknown as vscode.WorkspaceConfiguration + } + + if (section === "terminal.integrated.profiles") { + const profiles = { [profileName]: { path: profilePath } } + return { + inspect: (_key: string) => ({ + defaultValue: undefined, + globalValue: profiles, + workspaceValue: undefined, + }), + } as unknown as vscode.WorkspaceConfiguration + } + + return { + get: (_key: string, dv?: unknown) => dv, + inspect: () => undefined, + } as unknown as vscode.WorkspaceConfiguration + }) + mockedExistsSync.mockImplementation((p: string) => p === profilePath) + + const shellForSystemPrompt = getShell() + const terminalSeesProfileName = Terminal.getConfiguredDefaultProfileName("win32") + + // Both agree: Git Bash + expect(terminalSeesProfileName).toBe(profileName) + expect(shellForSystemPrompt).toBe(profilePath) + }) + + it("convergence: getShell() and Terminal both ignore a workspace-scoped-only profile (fix verification)", () => { + // beforeEach mocks existsSync to return true only for PS7 path. + // Here we want to test the no-profile fallback, so make existsSync return false. + mockedExistsSync.mockReturnValue(false) + stubWorkspaceScopedProfile("PowerShell", "C:\\Program Files\\PowerShell\\7\\pwsh.exe") + + // Terminal reads only inspect().globalValue → no profile configured at global scope. + const terminalSeesProfileName = Terminal.getConfiguredDefaultProfileName("win32") + expect(terminalSeesProfileName).toBeUndefined() + + // After the fix, getShell() delegates to Terminal's inspect()-based methods, + // so it also sees no profile. It falls back to the Windows no-profile default + // (PS legacy, since existsSync returns false for PS7 in this test). + const shellForSystemPrompt = getShell() + expect(shellForSystemPrompt).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") + + // Both paths agree: no profile resolved → no active shell identified as PowerShell. + expect(Terminal.isActiveShellPowerShell("win32")).toBe(false) + }) +}) diff --git a/src/package.json b/src/package.json index 9be6390cbc..55035dc206 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "ZooCodeOrganization", - "version": "3.76.0", + "version": "3.78.0", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", @@ -529,6 +529,7 @@ }, "devDependencies": { "@ai-sdk/openai-compatible": "2.0.56", + "@typescript-eslint/parser": "8.32.1", "@roo-code/build": "workspace:^", "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index 6ddaf181b4..b1397adf71 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "La família del model de llenguatge (p. ex. gpt-4)", "settings.customStoragePath.description": "Ruta d'emmagatzematge personalitzada. Deixeu-la buida per utilitzar la ubicació predeterminada. Admet rutes absolutes (p. ex. 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Habilitar correccions ràpides de Zoo Code.", - "settings.autoImportSettingsPath.description": "Ruta a un fitxer de configuració de ZooCode per importar automàticament en iniciar l'extensió. Admet rutes absolutes i rutes relatives al directori d'inici (per exemple, '~/Documents/roo-code-settings.json'). Deixeu-ho en blanc per desactivar la importació automàtica.", + "settings.autoImportSettingsPath.description": "Ruta a un fitxer de configuració de ZooCode per importar automàticament en iniciar l'extensió. Admet rutes absolutes i rutes relatives al directori d'inici (per exemple, '~/Documents/zoo-code-settings.json'). Deixeu-ho en blanc per desactivar la importació automàtica.", "settings.maximumIndexedFilesForFileSearch.description": "Nombre màxim de fitxers per indexar per a la funció de cerca de fitxers @. Valors més alts proporcionen millors resultats de cerca en projectes grans però poden utilitzar més memòria. Per defecte: 10.000.", "settings.useAgentRules.description": "Activa la càrrega de fitxers AGENTS.md per a regles específiques de l'agent (vegeu https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Temps d'espera de resposta API (segons, predeterminat: 600, rang: 1–3600). Es recomanen valors més alts per a proveïdors locals. Proveïdors no admesos: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.de.json b/src/package.nls.de.json index 4c8eccb293..b4cd8440ab 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "Die Familie des Sprachmodells (z.B. gpt-4)", "settings.customStoragePath.description": "Benutzerdefinierter Speicherpfad. Leer lassen, um den Standardspeicherort zu verwenden. Unterstützt absolute Pfade (z.B. 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Zoo Code Schnelle Problembehebung aktivieren.", - "settings.autoImportSettingsPath.description": "Pfad zu einer ZooCode-Konfigurationsdatei, die beim Start der Erweiterung automatisch importiert wird. Unterstützt absolute Pfade und Pfade relativ zum Home-Verzeichnis (z.B. '~/Documents/roo-code-settings.json'). Leer lassen, um den automatischen Import zu deaktivieren.", + "settings.autoImportSettingsPath.description": "Pfad zu einer ZooCode-Konfigurationsdatei, die beim Start der Erweiterung automatisch importiert wird. Unterstützt absolute Pfade und Pfade relativ zum Home-Verzeichnis (z.B. '~/Documents/zoo-code-settings.json'). Leer lassen, um den automatischen Import zu deaktivieren.", "settings.maximumIndexedFilesForFileSearch.description": "Maximale Anzahl der zu indizierenden Dateien für die @-Dateisuchfunktion. Höhere Werte bieten bessere Suchergebnisse in großen Projekten, können aber mehr Speicher verbrauchen. Standard: 10.000.", "settings.useAgentRules.description": "Aktiviert das Laden von AGENTS.md-Dateien für agentenspezifische Regeln (siehe https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API-Antwort-Timeout (Sekunden, Standard: 600, Bereich: 1–3600). Höhere Werte werden für lokale Anbieter empfohlen. Nicht unterstützte Anbieter: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 11a705880b..1d86a70980 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "La familia del modelo de lenguaje (ej. gpt-4)", "settings.customStoragePath.description": "Ruta de almacenamiento personalizada. Dejar vacío para usar la ubicación predeterminada. Admite rutas absolutas (ej. 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Habilitar correcciones rápidas de Zoo Code.", - "settings.autoImportSettingsPath.description": "Ruta a un archivo de configuración de ZooCode para importar automáticamente al iniciar la extensión. Admite rutas absolutas y rutas relativas al directorio de inicio (por ejemplo, '~/Documents/roo-code-settings.json'). Dejar vacío para desactivar la importación automática.", + "settings.autoImportSettingsPath.description": "Ruta a un archivo de configuración de ZooCode para importar automáticamente al iniciar la extensión. Admite rutas absolutas y rutas relativas al directorio de inicio (por ejemplo, '~/Documents/zoo-code-settings.json'). Dejar vacío para desactivar la importación automática.", "settings.maximumIndexedFilesForFileSearch.description": "Número máximo de archivos a indexar para la función de búsqueda de archivos @. Valores más altos proporcionan mejores resultados de búsqueda en proyectos grandes pero pueden usar más memoria. Por defecto: 10.000.", "settings.useAgentRules.description": "Habilita la carga de archivos AGENTS.md para reglas específicas del agente (ver https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Tiempo de espera de respuesta API (segundos, predeterminado: 600, rango: 1–3600). Se recomiendan valores más altos para proveedores locales. Proveedores no compatibles: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 573350bc9a..85207df975 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "La famille du modèle de langage (ex: gpt-4)", "settings.customStoragePath.description": "Chemin de stockage personnalisé. Laisser vide pour utiliser l'emplacement par défaut. Prend en charge les chemins absolus (ex: 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Activer les correctifs rapides de Zoo Code.", - "settings.autoImportSettingsPath.description": "Chemin d'accès à un fichier de configuration ZooCode à importer automatiquement au démarrage de l'extension. Prend en charge les chemins absolus et les chemins relatifs au répertoire de base (par exemple, '~/Documents/roo-code-settings.json'). Laisser vide pour désactiver l'importation automatique.", + "settings.autoImportSettingsPath.description": "Chemin d'accès à un fichier de configuration ZooCode à importer automatiquement au démarrage de l'extension. Prend en charge les chemins absolus et les chemins relatifs au répertoire de base (par exemple, '~/Documents/zoo-code-settings.json'). Laisser vide pour désactiver l'importation automatique.", "settings.maximumIndexedFilesForFileSearch.description": "Nombre maximum de fichiers à indexer pour la fonctionnalité de recherche de fichiers @. Des valeurs plus élevées offrent de meilleurs résultats de recherche dans les grands projets mais peuvent consommer plus de mémoire. Par défaut : 10 000.", "settings.useAgentRules.description": "Activer le chargement des fichiers AGENTS.md pour les règles spécifiques à l'agent (voir https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Délai d'attente des réponses API (secondes, défaut : 600, plage : 1–3600). Des valeurs plus élevées sont recommandées pour les fournisseurs locaux. Fournisseurs non pris en charge : Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index 8135af2ab3..f4f283bb65 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "भाषा मॉडल का परिवार (उदा. gpt-4)", "settings.customStoragePath.description": "कस्टम स्टोरेज पाथ। डिफ़ॉल्ट स्थान का उपयोग करने के लिए खाली छोड़ें। पूर्ण पथ का समर्थन करता है (उदा. 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Zoo Code त्वरित सुधार सक्षम करें", - "settings.autoImportSettingsPath.description": "ZooCode कॉन्फ़िगरेशन फ़ाइल का पथ जिसे एक्सटेंशन स्टार्टअप पर स्वचालित रूप से आयात किया जाएगा। होम डायरेक्टरी के सापेक्ष पूर्ण पथ और पथों का समर्थन करता है (उदाहरण के लिए '~/Documents/roo-code-settings.json')। ऑटो-इंपोर्ट को अक्षम करने के लिए खाली छोड़ दें।", + "settings.autoImportSettingsPath.description": "ZooCode कॉन्फ़िगरेशन फ़ाइल का पथ जिसे एक्सटेंशन स्टार्टअप पर स्वचालित रूप से आयात किया जाएगा। होम डायरेक्टरी के सापेक्ष पूर्ण पथ और पथों का समर्थन करता है (उदाहरण के लिए '~/Documents/zoo-code-settings.json')। ऑटो-इंपोर्ट को अक्षम करने के लिए खाली छोड़ दें।", "settings.maximumIndexedFilesForFileSearch.description": "@ फ़ाइल खोज सुविधा के लिए अनुक्रमित करने के लिए फ़ाइलों की अधिकतम संख्या। उच्च मान बड़ी परियोजनाओं में बेहतर खोज परिणाम प्रदान करते हैं लेकिन अधिक मेमोरी का उपयोग कर सकते हैं। डिफ़ॉल्ट: 10,000।", "settings.useAgentRules.description": "एजेंट-विशिष्ट नियमों के लिए AGENTS.md फ़ाइलों को लोड करना सक्षम करें (देखें https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API प्रतिक्रिया टाइमआउट (सेकंड, डिफ़ॉल्ट: 600, रेंज: 1–3600)। स्थानीय प्रदाताओं के लिए उच्च मान अनुशंसित हैं। असमर्थित प्रदाता: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API।", diff --git a/src/package.nls.id.json b/src/package.nls.id.json index c5740ad00b..c49e54a34e 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "Keluarga dari model bahasa (misalnya gpt-4)", "settings.customStoragePath.description": "Path penyimpanan kustom. Biarkan kosong untuk menggunakan lokasi default. Mendukung path absolut (misalnya 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Aktifkan perbaikan cepat Zoo Code.", - "settings.autoImportSettingsPath.description": "Path ke file konfigurasi ZooCode untuk diimpor secara otomatis saat ekstensi dimulai. Mendukung path absolut dan path relatif terhadap direktori home (misalnya '~/Documents/roo-code-settings.json'). Biarkan kosong untuk menonaktifkan impor otomatis.", + "settings.autoImportSettingsPath.description": "Path ke file konfigurasi ZooCode untuk diimpor secara otomatis saat ekstensi dimulai. Mendukung path absolut dan path relatif terhadap direktori home (misalnya '~/Documents/zoo-code-settings.json'). Biarkan kosong untuk menonaktifkan impor otomatis.", "settings.maximumIndexedFilesForFileSearch.description": "Jumlah maksimum file yang akan diindeks untuk fitur pencarian file @. Nilai yang lebih besar memberikan hasil pencarian yang lebih baik di proyek besar tetapi mungkin menggunakan lebih banyak memori. Default: 10.000.", "settings.useAgentRules.description": "Aktifkan pemuatan file AGENTS.md untuk aturan khusus agen (lihat https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Batas waktu respons API (detik, default: 600, rentang: 1–3600). Nilai lebih tinggi direkomendasikan untuk penyedia lokal. Penyedia tidak didukung: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.it.json b/src/package.nls.it.json index ebf2167a99..2a4b95f954 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "La famiglia del modello linguistico (es. gpt-4)", "settings.customStoragePath.description": "Percorso di archiviazione personalizzato. Lasciare vuoto per utilizzare la posizione predefinita. Supporta percorsi assoluti (es. 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Abilita correzioni rapide di Zoo Code.", - "settings.autoImportSettingsPath.description": "Percorso di un file di configurazione di ZooCode da importare automaticamente all'avvio dell'estensione. Supporta percorsi assoluti e percorsi relativi alla directory home (ad es. '~/Documents/roo-code-settings.json'). Lasciare vuoto per disabilitare l'importazione automatica.", + "settings.autoImportSettingsPath.description": "Percorso di un file di configurazione di ZooCode da importare automaticamente all'avvio dell'estensione. Supporta percorsi assoluti e percorsi relativi alla directory home (ad es. '~/Documents/zoo-code-settings.json'). Lasciare vuoto per disabilitare l'importazione automatica.", "settings.maximumIndexedFilesForFileSearch.description": "Numero massimo di file da indicizzare per la funzionalità di ricerca file @. Valori più alti forniscono migliori risultati di ricerca in progetti grandi ma possono consumare più memoria. Predefinito: 10.000.", "settings.useAgentRules.description": "Abilita il caricamento dei file AGENTS.md per regole specifiche dell'agente (vedi https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Timeout risposta API (secondi, predefinito: 600, intervallo: 1–3600). Valori più alti sono consigliati per provider locali. Provider non supportati: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index f9daa4bb93..0f3f4a3cee 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "言語モデルのファミリー(例:gpt-4)", "settings.customStoragePath.description": "カスタムストレージパス。デフォルトの場所を使用する場合は空のままにします。絶対パスをサポートします(例:'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Zoo Codeのクイック修正を有効にする。", - "settings.autoImportSettingsPath.description": "拡張機能の起動時に自動的にインポートするZooCode設定ファイルへのパス。絶対パスとホームディレクトリからの相対パスをサポートします(例:'~/Documents/roo-code-settings.json')。自動インポートを無効にするには、空のままにします。", + "settings.autoImportSettingsPath.description": "拡張機能の起動時に自動的にインポートするZooCode設定ファイルへのパス。絶対パスとホームディレクトリからの相対パスをサポートします(例:'~/Documents/zoo-code-settings.json')。自動インポートを無効にするには、空のままにします。", "settings.maximumIndexedFilesForFileSearch.description": "@ファイル検索機能のためにインデックス化するファイルの最大数。大きな値は大規模プロジェクトでより良い検索結果を提供しますが、より多くのメモリを使用する可能性があります。デフォルト: 10,000。", "settings.useAgentRules.description": "エージェント固有のルールのためにAGENTS.mdファイルの読み込みを有効にします(参照:https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API応答タイムアウト(秒、デフォルト:600、範囲:1–3600)。ローカルプロバイダーには高い値を推奨します。非対応プロバイダー:Amazon Bedrock、Google Gemini(directly or through the Vertex AI platform)、Mistral、Moonshot、Ollama、Poe、VS Code LM API。", diff --git a/src/package.nls.json b/src/package.nls.json index 4fac644eab..fe10839819 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "The family of the language model (e.g. gpt-4)", "settings.customStoragePath.description": "Custom storage path. Leave empty to use the default location. Supports absolute paths (e.g. 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Enable Zoo Code quick fixes", - "settings.autoImportSettingsPath.description": "Path to a ZooCode configuration file to automatically import on extension startup. Supports absolute paths and paths relative to the home directory (e.g. '~/Documents/roo-code-settings.json'). Leave empty to disable auto-import.", + "settings.autoImportSettingsPath.description": "Path to a ZooCode configuration file to automatically import on extension startup. Supports absolute paths and paths relative to the home directory (e.g. '~/Documents/zoo-code-settings.json'). Leave empty to disable auto-import.", "settings.maximumIndexedFilesForFileSearch.description": "Maximum number of files to index for the @ file search feature. Higher values provide better search results in large projects but may use more memory. Default: 10,000.", "settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API response timeout (seconds, default: 600, range: 1–3600). Higher values are recommended for local providers. Unsupported providers: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index a743902280..99a064cd6c 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "언어 모델 계열 (예: gpt-4)", "settings.customStoragePath.description": "사용자 지정 저장소 경로. 기본 위치를 사용하려면 비워두세요. 절대 경로를 지원합니다 (예: 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Zoo Code 빠른 수정 사용 설정", - "settings.autoImportSettingsPath.description": "확장 프로그램 시작 시 자동으로 가져올 ZooCode 구성 파일의 경로입니다. 절대 경로 및 홈 디렉토리에 대한 상대 경로를 지원합니다(예: '~/Documents/roo-code-settings.json'). 자동 가져오기를 비활성화하려면 비워 둡니다.", + "settings.autoImportSettingsPath.description": "확장 프로그램 시작 시 자동으로 가져올 ZooCode 구성 파일의 경로입니다. 절대 경로 및 홈 디렉토리에 대한 상대 경로를 지원합니다(예: '~/Documents/zoo-code-settings.json'). 자동 가져오기를 비활성화하려면 비워 둡니다.", "settings.maximumIndexedFilesForFileSearch.description": "@ 파일 검색 기능을 위해 인덱싱할 최대 파일 수입니다. 더 큰 값은 대형 프로젝트에서 더 나은 검색 결과를 제공하지만 더 많은 메모리를 사용할 수 있습니다. 기본값: 10,000.", "settings.useAgentRules.description": "에이전트별 규칙에 대한 AGENTS.md 파일 로드를 활성화합니다 (참조: https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API 응답 대기 시간(초, 기본값: 600, 범위: 1~3600). 로컬 공급자에는 더 높은 값을 권장합니다. 지원되지 않는 공급자: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 72bc15f89a..27948f933a 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "De familie van het taalmodel (bijv. gpt-4)", "settings.customStoragePath.description": "Aangepast opslagpad. Laat leeg om de standaardlocatie te gebruiken. Ondersteunt absolute paden (bijv. 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Snelle correcties van Zoo Code inschakelen.", - "settings.autoImportSettingsPath.description": "Pad naar een ZooCode-configuratiebestand om automatisch te importeren bij het opstarten van de extensie. Ondersteunt absolute paden en paden ten opzichte van de thuismap (bijv. '~/Documents/roo-code-settings.json'). Laat leeg om automatisch importeren uit te schakelen.", + "settings.autoImportSettingsPath.description": "Pad naar een ZooCode-configuratiebestand om automatisch te importeren bij het opstarten van de extensie. Ondersteunt absolute paden en paden ten opzichte van de thuismap (bijv. '~/Documents/zoo-code-settings.json'). Laat leeg om automatisch importeren uit te schakelen.", "settings.maximumIndexedFilesForFileSearch.description": "Maximaal aantal bestanden om te indexeren voor de @ bestandszoekfunctie. Hogere waarden bieden betere zoekresultaten in grote projecten maar kunnen meer geheugen gebruiken. Standaard: 10.000.", "settings.useAgentRules.description": "Laden van AGENTS.md-bestanden voor agentspecifieke regels inschakelen (zie https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API-respons time-out (seconden, standaard: 600, bereik: 1–3600). Hogere waarden worden aanbevolen voor lokale providers. Niet-ondersteunde providers: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 92fb97778b..8fb8b6eb83 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "Rodzina modelu językowego (np. gpt-4)", "settings.customStoragePath.description": "Niestandardowa ścieżka przechowywania. Pozostaw puste, aby użyć domyślnej lokalizacji. Obsługuje ścieżki bezwzględne (np. 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Włącz szybkie poprawki Zoo Code.", - "settings.autoImportSettingsPath.description": "Ścieżka do pliku konfiguracyjnego ZooCode, który ma być automatycznie importowany podczas uruchamiania rozszerzenia. Obsługuje ścieżki bezwzględne i ścieżki względne do katalogu domowego (np. '~/Documents/roo-code-settings.json'). Pozostaw puste, aby wyłączyć automatyczne importowanie.", + "settings.autoImportSettingsPath.description": "Ścieżka do pliku konfiguracyjnego ZooCode, który ma być automatycznie importowany podczas uruchamiania rozszerzenia. Obsługuje ścieżki bezwzględne i ścieżki względne do katalogu domowego (np. '~/Documents/zoo-code-settings.json'). Pozostaw puste, aby wyłączyć automatyczne importowanie.", "settings.maximumIndexedFilesForFileSearch.description": "Maksymalna liczba plików do indeksowania dla funkcji wyszukiwania plików @. Wyższe wartości zapewniają lepsze wyniki wyszukiwania w dużych projektach, ale mogą zużywać więcej pamięci. Domyślnie: 10 000.", "settings.useAgentRules.description": "Włącz wczytywanie plików AGENTS.md dla reguł specyficznych dla agenta (zobacz https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Limit czasu odpowiedzi API (sekundy, domyślnie: 600, zakres: 1–3600). Wyższe wartości są zalecane dla lokalnych dostawców. Nieobsługiwani dostawcy: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index 872af10e80..b4cae4644b 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "A família do modelo de linguagem (ex: gpt-4)", "settings.customStoragePath.description": "Caminho de armazenamento personalizado. Deixe vazio para usar o local padrão. Suporta caminhos absolutos (ex: 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Habilitar correções rápidas do Zoo Code.", - "settings.autoImportSettingsPath.description": "Caminho para um arquivo de configuração do ZooCode para importar automaticamente na inicialização da extensão. Suporta caminhos absolutos e caminhos relativos ao diretório inicial (por exemplo, '~/Documents/roo-code-settings.json'). Deixe em branco para desativar a importação automática.", + "settings.autoImportSettingsPath.description": "Caminho para um arquivo de configuração do ZooCode para importar automaticamente na inicialização da extensão. Suporta caminhos absolutos e caminhos relativos ao diretório inicial (por exemplo, '~/Documents/zoo-code-settings.json'). Deixe em branco para desativar a importação automática.", "settings.maximumIndexedFilesForFileSearch.description": "Número máximo de arquivos a indexar para a funcionalidade de busca de arquivos @. Valores maiores fornecem melhores resultados de busca em projetos grandes, mas podem consumir mais memória. Padrão: 10.000.", "settings.useAgentRules.description": "Habilita o carregamento de arquivos AGENTS.md para regras específicas do agente (consulte https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Tempo limite de resposta da API (segundos, padrão: 600, intervalo: 1–3600). Valores mais altos são recomendados para provedores locais. Provedores não suportados: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index cb38655945..0e61002848 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "Семейство языковой модели (например, gpt-4)", "settings.customStoragePath.description": "Пользовательский путь хранения. Оставьте пустым для использования пути по умолчанию. Поддерживает абсолютные пути (например, 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Включить быстрые исправления Zoo Code.", - "settings.autoImportSettingsPath.description": "Путь к файлу конфигурации ZooCode для автоматического импорта при запуске расширения. Поддерживает абсолютные пути и пути относительно домашнего каталога (например, '~/Documents/roo-code-settings.json'). Оставьте пустым, чтобы отключить автоматический импорт.", + "settings.autoImportSettingsPath.description": "Путь к файлу конфигурации ZooCode для автоматического импорта при запуске расширения. Поддерживает абсолютные пути и пути относительно домашнего каталога (например, '~/Documents/zoo-code-settings.json'). Оставьте пустым, чтобы отключить автоматический импорт.", "settings.maximumIndexedFilesForFileSearch.description": "Максимальное количество файлов для индексации при поиске файлов @. Большие значения обеспечивают лучшие результаты поиска в крупных проектах, но могут потреблять больше памяти. По умолчанию: 10 000.", "settings.useAgentRules.description": "Включить загрузку файлов AGENTS.md для специфичных для агента правил (см. https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Тайм-аут ответа API (секунды, по умолчанию: 600, диапазон: 1–3600). Более высокие значения рекомендуются для локальных провайдеров. Неподдерживаемые провайдеры: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index 7d995723ce..1842156d75 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "Dil modelinin ailesi (örn: gpt-4)", "settings.customStoragePath.description": "Özel depolama yolu. Varsayılan konumu kullanmak için boş bırakın. Mutlak yolları destekler (örn: 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Zoo Code hızlı düzeltmeleri etkinleştir.", - "settings.autoImportSettingsPath.description": "Uzantı başlangıcında otomatik olarak içe aktarılacak bir ZooCode yapılandırma dosyasının yolu. Mutlak yolları ve ana dizine göreli yolları destekler (ör. '~/Documents/roo-code-settings.json'). Otomatik içe aktarmayı devre dışı bırakmak için boş bırakın.", + "settings.autoImportSettingsPath.description": "Uzantı başlangıcında otomatik olarak içe aktarılacak bir ZooCode yapılandırma dosyasının yolu. Mutlak yolları ve ana dizine göreli yolları destekler (ör. '~/Documents/zoo-code-settings.json'). Otomatik içe aktarmayı devre dışı bırakmak için boş bırakın.", "settings.maximumIndexedFilesForFileSearch.description": "@ dosya arama özelliği için dizinlenecek maksimum dosya sayısı. Daha yüksek değerler büyük projelerde daha iyi arama sonuçları sağlar ancak daha fazla bellek kullanabilir. Varsayılan: 10.000.", "settings.useAgentRules.description": "Aracıya özgü kurallar için AGENTS.md dosyalarının yüklenmesini etkinleştirin (bkz. https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API yanıt zaman aşımı (saniye, varsayılan: 600, aralık: 1–3600). Yerel sağlayıcılar için daha yüksek değerler önerilir. Desteklenmeyen sağlayıcılar: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index b50e4db508..c846978793 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "Họ mô hình ngôn ngữ (ví dụ: gpt-4)", "settings.customStoragePath.description": "Đường dẫn lưu trữ tùy chỉnh. Để trống để sử dụng vị trí mặc định. Hỗ trợ đường dẫn tuyệt đối (ví dụ: 'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "Bật sửa lỗi nhanh Zoo Code.", - "settings.autoImportSettingsPath.description": "Đường dẫn đến tệp cấu hình ZooCode để tự động nhập khi khởi động tiện ích mở rộng. Hỗ trợ đường dẫn tuyệt đối và đường dẫn tương đối đến thư mục chính (ví dụ: '~/Documents/roo-code-settings.json'). Để trống để tắt tính năng tự động nhập.", + "settings.autoImportSettingsPath.description": "Đường dẫn đến tệp cấu hình ZooCode để tự động nhập khi khởi động tiện ích mở rộng. Hỗ trợ đường dẫn tuyệt đối và đường dẫn tương đối đến thư mục chính (ví dụ: '~/Documents/zoo-code-settings.json'). Để trống để tắt tính năng tự động nhập.", "settings.maximumIndexedFilesForFileSearch.description": "Số lượng tệp tối đa để lập chỉ mục cho tính năng tìm kiếm tệp @. Giá trị cao hơn cung cấp kết quả tìm kiếm tốt hơn trong các dự án lớn nhưng có thể sử dụng nhiều bộ nhớ hơn. Mặc định: 10.000.", "settings.useAgentRules.description": "Bật tải tệp AGENTS.md cho các quy tắc dành riêng cho tác nhân (xem https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Thời gian chờ phản hồi API (giây, mặc định: 600, phạm vi: 1–3600). Nên dùng giá trị cao hơn cho các nhà cung cấp cục bộ. Nhà cung cấp không được hỗ trợ: Amazon Bedrock, Google Gemini(directly or through the Vertex AI platform), Mistral, Moonshot, Ollama, Poe, VS Code LM API.", diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 0686d03a14..e2705e9e2f 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "语言模型的系列(例如:gpt-4)", "settings.customStoragePath.description": "自定义存储路径。留空以使用默认位置。支持绝对路径(例如:'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "启用 Zoo Code 快速修复", - "settings.autoImportSettingsPath.description": "ZooCode 配置文件的路径,用于在扩展启动时自动导入。支持绝对路径和相对于主目录的路径(例如 '~/Documents/roo-code-settings.json')。留空以禁用自动导入。", + "settings.autoImportSettingsPath.description": "ZooCode 配置文件的路径,用于在扩展启动时自动导入。支持绝对路径和相对于主目录的路径(例如 '~/Documents/zoo-code-settings.json')。留空以禁用自动导入。", "settings.maximumIndexedFilesForFileSearch.description": "为 @ 文件搜索功能建立索引时要索引的最大文件数。较大的值在大型项目中提供更好的搜索结果,但可能占用更多内存。默认值:10,000。", "settings.useAgentRules.description": "为特定于代理的规则启用 AGENTS.md 文件的加载(请参阅 https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API 响应超时(秒,默认值:600,范围:1–3600)。建议本地提供商使用更高的值。不适用的提供商:Amazon Bedrock、Google Gemini(directly or through the Vertex AI platform)、Mistral、Moonshot、Ollama、Poe、VS Code LM API。", diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index 8005e0de7f..723d8d08ad 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -36,7 +36,7 @@ "settings.vsCodeLmModelSelector.family.description": "語言模型系列(例如:gpt-4)", "settings.customStoragePath.description": "自訂儲存路徑。留空以使用預設位置。支援絕對路徑(例如:'D:\\ZooCodeStorage')", "settings.enableCodeActions.description": "啟用 Zoo Code 快速修復。", - "settings.autoImportSettingsPath.description": "ZooCode 設定檔案的路徑,用於在擴充功能啟動時自動匯入。支援絕對路徑和相對於主目錄的路徑(例如 '~/Documents/roo-code-settings.json')。留空以停用自動匯入。", + "settings.autoImportSettingsPath.description": "ZooCode 設定檔案的路徑,用於在擴充功能啟動時自動匯入。支援絕對路徑和相對於主目錄的路徑(例如 '~/Documents/zoo-code-settings.json')。留空以停用自動匯入。", "settings.maximumIndexedFilesForFileSearch.description": "為 @ 檔案搜尋功能建立索引時要索引的最大檔案數。較大的值在大型專案中提供更好的搜尋結果,但可能佔用更多記憶體。預設值:10,000。", "settings.useAgentRules.description": "為特定於代理的規則啟用 AGENTS.md 檔案的載入(請參閱 https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API 回應逾時(秒,預設值:600,範圍:1–3600)。建議本地提供商使用更高的值。不適用的提供商:Amazon Bedrock、Google Gemini(directly or through the Vertex AI platform)、Mistral、Moonshot、Ollama、Poe、VS Code LM API。", diff --git a/src/services/code-index/__tests__/config-manager.spec.ts b/src/services/code-index/__tests__/config-manager.spec.ts index 665c83314f..6a496b6809 100644 --- a/src/services/code-index/__tests__/config-manager.spec.ts +++ b/src/services/code-index/__tests__/config-manager.spec.ts @@ -13,6 +13,7 @@ vi.mock("../../../shared/embeddingModels") // Import mocked functions import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "../../../shared/embeddingModels" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Type the mocked functions const mockedGetDefaultModelId = vi.mocked(getDefaultModelId) @@ -102,7 +103,7 @@ describe("CodeIndexConfigManager", () => { expect(result.currentConfig).toEqual({ isConfigured: false, - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: undefined, openAiOptions: { openAiNativeApiKey: "" }, ollamaOptions: { ollamaBaseUrl: "" }, @@ -118,7 +119,7 @@ describe("CodeIndexConfigManager", () => { const mockGlobalState = { codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderBaseUrl: "", codebaseIndexEmbedderModelId: "text-embedding-3-large", } @@ -134,7 +135,7 @@ describe("CodeIndexConfigManager", () => { expect(result.currentConfig).toMatchObject({ isConfigured: true, - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-large", openAiOptions: { openAiNativeApiKey: "test-openai-key" }, ollamaOptions: { ollamaBaseUrl: "" }, @@ -301,7 +302,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-large", }) setupSecretMocks({ @@ -314,7 +315,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, codebaseIndexEmbedderBaseUrl: "http://ollama.local", codebaseIndexEmbedderModelId: "nomic-embed-text", }) @@ -328,7 +329,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -342,7 +343,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-large", }) @@ -363,7 +364,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -376,7 +377,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-ada-002", }) @@ -396,7 +397,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -414,7 +415,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -439,7 +440,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://old-qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -453,7 +454,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://new-qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) @@ -466,7 +467,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -480,7 +481,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "unknown-model", }) @@ -493,7 +494,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, codebaseIndexEmbedderBaseUrl: "http://old-ollama.local", codebaseIndexEmbedderModelId: "nomic-embed-text", }) @@ -504,7 +505,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, codebaseIndexEmbedderBaseUrl: "http://new-ollama.local", codebaseIndexEmbedderModelId: "nomic-embed-text", }) @@ -753,7 +754,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, }) setupSecretMocks({}) @@ -763,7 +764,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, codebaseIndexEmbedderBaseUrl: "http://ollama.local", }) @@ -777,7 +778,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, }) setupSecretMocks({}) @@ -787,7 +788,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-large", }) @@ -800,7 +801,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", codebaseIndexSearchMinScore: 0.8, // User setting }) @@ -816,7 +817,7 @@ 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") { + if (provider === providerIdentifiers.ollama && modelId === "nomic-embed-code") { return 0.15 } return undefined @@ -825,7 +826,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, codebaseIndexEmbedderModelId: "nomic-embed-code", // No codebaseIndexSearchMinScore - user hasn't configured it }) @@ -839,7 +840,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "unknown-model", // Model not in profiles // No codebaseIndexSearchMinScore }) @@ -857,7 +858,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, codebaseIndexEmbedderModelId: "nomic-embed-code", codebaseIndexSearchMinScore: 0, // User explicitly sets 0 }) @@ -903,7 +904,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, // No modelId specified // No codebaseIndexSearchMinScore }) @@ -920,7 +921,7 @@ 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") { + if (provider === providerIdentifiers.ollama && modelId === "nomic-embed-code") { return 0.15 } return undefined @@ -930,7 +931,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, codebaseIndexEmbedderModelId: "nomic-embed-code", // Has 0.15 threshold codebaseIndexSearchMinScore: 0.9, // User overrides }) @@ -942,7 +943,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, codebaseIndexEmbedderModelId: "nomic-embed-code", // No user setting }) @@ -955,7 +956,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "custom-unknown-model", // No user setting, unknown model }) @@ -972,7 +973,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", codebaseIndexSearchMaxResults: 150, // User setting }) @@ -984,7 +985,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", // No user setting }) @@ -997,7 +998,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", codebaseIndexSearchMaxResults: 10, // Minimum allowed }) @@ -1010,7 +1011,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", codebaseIndexSearchMaxResults: 200, // Maximum allowed }) @@ -1028,7 +1029,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({}) @@ -1039,7 +1040,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", codebaseIndexSearchMinScore: 0.5, // Changed unrelated setting }) @@ -1054,7 +1055,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, // Always enabled now codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, }) setupSecretMocks({}) @@ -1076,7 +1077,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, }) setupSecretMocks({ codeIndexOpenAiKey: "", @@ -1103,7 +1104,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -1117,7 +1118,7 @@ describe("CodeIndexConfigManager", () => { const mockPrevConfig = { enabled: true, configured: true, - embedderProvider: "openai" as const, + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-large", // Different model with different dimensions openAiKey: "test-key", ollamaBaseUrl: undefined, @@ -1149,7 +1150,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -1183,7 +1184,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -1216,7 +1217,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, }) setupSecretMocks({ codeIndexOpenAiKey: "test-key", @@ -1231,7 +1232,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, codebaseIndexEmbedderBaseUrl: "http://ollama.local", }) @@ -1306,7 +1307,7 @@ describe("CodeIndexConfigManager", () => { return { codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "gemini", + codebaseIndexEmbedderProvider: providerIdentifiers.gemini, } } return undefined @@ -1326,7 +1327,7 @@ describe("CodeIndexConfigManager", () => { return { codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "gemini", + codebaseIndexEmbedderProvider: providerIdentifiers.gemini, } } return undefined @@ -1343,7 +1344,7 @@ describe("CodeIndexConfigManager", () => { it("should return false when required values are missing", async () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, }) await configManager.loadConfiguration() @@ -1356,7 +1357,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-large", }) setupSecretMocks({ @@ -1371,7 +1372,7 @@ describe("CodeIndexConfigManager", () => { const config = configManager.getConfig() expect(config).toMatchObject({ isConfigured: true, - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-large", openAiOptions: { openAiNativeApiKey: "test-openai-key" }, ollamaOptions: { ollamaBaseUrl: undefined }, @@ -1410,7 +1411,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -1430,7 +1431,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, // Always enabled now codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -1453,7 +1454,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", }) setupSecretMocks({ @@ -1475,7 +1476,7 @@ describe("CodeIndexConfigManager", () => { // Initial state: disabled mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: false, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexQdrantUrl: "http://localhost:6333", }) mockContextProxy.getSecret.mockReturnValue(undefined) @@ -1487,7 +1488,7 @@ describe("CodeIndexConfigManager", () => { // Update the internal state to enabled with proper configuration mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexQdrantUrl: "http://localhost:6333", }) mockContextProxy.getSecret.mockImplementation((key: string) => { @@ -1505,7 +1506,7 @@ describe("CodeIndexConfigManager", () => { // Initial state: enabled and configured mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexQdrantUrl: "http://localhost:6333", }) mockContextProxy.getSecret.mockImplementation((key: string) => { @@ -1517,7 +1518,7 @@ describe("CodeIndexConfigManager", () => { const previousSnapshot: PreviousConfigSnapshot = { enabled: true, configured: true, - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, openAiKey: "test-key", qdrantUrl: "http://localhost:6333", } @@ -1525,7 +1526,7 @@ describe("CodeIndexConfigManager", () => { // Update to disabled mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: false, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexQdrantUrl: "http://localhost:6333", }) mockContextProxy.getSecret.mockImplementation((key: string) => { @@ -1543,7 +1544,7 @@ describe("CodeIndexConfigManager", () => { // Initial state: enabled and configured mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexQdrantUrl: "http://localhost:6333", }) mockContextProxy.getSecret.mockImplementation((key: string) => { @@ -1572,7 +1573,7 @@ describe("CodeIndexConfigManager", () => { const previousSnapshot: PreviousConfigSnapshot = { enabled: false, configured: false, - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, } // Same config, still disabled @@ -1584,7 +1585,7 @@ describe("CodeIndexConfigManager", () => { // Initial state: enabled with openai mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, codebaseIndexOllamaBaseUrl: "http://localhost:11434", codebaseIndexQdrantUrl: "http://localhost:6333", }) @@ -1594,7 +1595,7 @@ describe("CodeIndexConfigManager", () => { const previousSnapshot: PreviousConfigSnapshot = { enabled: true, configured: true, - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, openAiKey: "test-key", qdrantUrl: "http://localhost:6333", } @@ -1607,7 +1608,7 @@ describe("CodeIndexConfigManager", () => { // Initial state: disabled with openai mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: false, - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, }) mockContextProxy.getSecret.mockReturnValue(undefined) configManager = new CodeIndexConfigManager(mockContextProxy) @@ -1615,7 +1616,7 @@ describe("CodeIndexConfigManager", () => { const previousSnapshot: PreviousConfigSnapshot = { enabled: false, configured: false, - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, } // Provider changed but feature is disabled @@ -1635,7 +1636,7 @@ describe("CodeIndexConfigManager", () => { it("should load configuration and return proper structure", async () => { const mockConfigValues = { codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-ada-002", codebaseIndexQdrantUrl: "http://localhost:6333", codebaseIndexSearchMinScore: 0.5, @@ -1665,7 +1666,7 @@ describe("CodeIndexConfigManager", () => { // Initial state: disabled mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: false, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexQdrantUrl: "http://localhost:6333", }) mockContextProxy.getSecret.mockReturnValue(undefined) @@ -1677,7 +1678,7 @@ describe("CodeIndexConfigManager", () => { // Change to enabled with proper configuration mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexQdrantUrl: "http://localhost:6333", }) mockContextProxy.getSecret.mockImplementation((key: string) => { @@ -1694,7 +1695,7 @@ describe("CodeIndexConfigManager", () => { it("should return the current configuration", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexQdrantUrl: "http://localhost:6333", }) mockContextProxy.getSecret.mockImplementation((key: string) => { @@ -1715,7 +1716,7 @@ describe("CodeIndexConfigManager", () => { it("should return true when OpenAI provider is properly configured", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexQdrantUrl: "http://localhost:6333", }) mockContextProxy.getSecret.mockImplementation((key: string) => { @@ -1730,7 +1731,7 @@ describe("CodeIndexConfigManager", () => { it("should return false when OpenAI provider is missing API key", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexQdrantUrl: "http://localhost:6333", }) mockContextProxy.getSecret.mockReturnValue(undefined) @@ -1742,7 +1743,7 @@ describe("CodeIndexConfigManager", () => { it("should return true when Ollama provider is properly configured", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderProvider: providerIdentifiers.ollama, codebaseIndexEmbedderBaseUrl: "http://localhost:11434", codebaseIndexQdrantUrl: "http://localhost:6333", }) @@ -1755,7 +1756,7 @@ describe("CodeIndexConfigManager", () => { it("should return false when Qdrant URL is missing", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, }) mockContextProxy.getSecret.mockImplementation((key: string) => { if (key === "codeIndexOpenAiKey") return "test-key" @@ -1801,7 +1802,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", codebaseIndexEmbedderModelDimension: 2048, // Custom dimension should be ignored codebaseIndexQdrantUrl: "http://localhost:6333", @@ -1874,7 +1875,7 @@ describe("CodeIndexConfigManager", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, // No modelId specified codebaseIndexQdrantUrl: "http://localhost:6333", }) @@ -1919,7 +1920,7 @@ describe("CodeIndexConfigManager", () => { it("should correctly handle OpenRouter mistral model dimensions across restarts", async () => { // Mock getModelDimension to return correct dimensions for OpenRouter models mockedGetModelDimension.mockImplementation((provider, modelId) => { - if (provider === "openrouter") { + if (provider === providerIdentifiers.openrouter) { if (modelId === "mistralai/codestral-embed-2505") return 1536 if (modelId === "mistralai/mistral-embed-2312") return 1024 if (modelId === "openai/text-embedding-3-large") return 3072 @@ -1930,7 +1931,7 @@ describe("CodeIndexConfigManager", () => { // Initial configuration with OpenRouter and Mistral model mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openrouter", + codebaseIndexEmbedderProvider: providerIdentifiers.openrouter, codebaseIndexEmbedderModelId: "mistralai/codestral-embed-2505", codebaseIndexQdrantUrl: "http://localhost:6333", }) @@ -1959,7 +1960,7 @@ describe("CodeIndexConfigManager", () => { it("should not require restart for OpenRouter when same model dimensions are used", async () => { // Mock both models to have same dimension mockedGetModelDimension.mockImplementation((provider, modelId) => { - if (provider === "openrouter") { + if (provider === providerIdentifiers.openrouter) { if (modelId === "mistralai/codestral-embed-2505") return 1536 if (modelId === "openai/text-embedding-3-small") return 1536 } @@ -1969,7 +1970,7 @@ describe("CodeIndexConfigManager", () => { // Initial state with OpenRouter and Mistral model mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openrouter", + codebaseIndexEmbedderProvider: providerIdentifiers.openrouter, codebaseIndexEmbedderModelId: "mistralai/codestral-embed-2505", codebaseIndexQdrantUrl: "http://localhost:6333", }) @@ -1984,7 +1985,7 @@ describe("CodeIndexConfigManager", () => { // Change to another model with same dimension mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openrouter", + codebaseIndexEmbedderProvider: providerIdentifiers.openrouter, codebaseIndexEmbedderModelId: "openai/text-embedding-3-small", // Same 1536 dimension codebaseIndexQdrantUrl: "http://localhost:6333", }) @@ -1997,7 +1998,7 @@ describe("CodeIndexConfigManager", () => { it("should require restart for OpenRouter when model dimensions change", async () => { // Mock models with different dimensions mockedGetModelDimension.mockImplementation((provider, modelId) => { - if (provider === "openrouter") { + if (provider === providerIdentifiers.openrouter) { if (modelId === "mistralai/codestral-embed-2505") return 1536 if (modelId === "mistralai/mistral-embed-2312") return 1024 } @@ -2007,7 +2008,7 @@ describe("CodeIndexConfigManager", () => { // Initial state with 1536-dimension model mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openrouter", + codebaseIndexEmbedderProvider: providerIdentifiers.openrouter, codebaseIndexEmbedderModelId: "mistralai/codestral-embed-2505", codebaseIndexQdrantUrl: "http://localhost:6333", }) @@ -2022,7 +2023,7 @@ describe("CodeIndexConfigManager", () => { // Change to model with different dimension mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexEmbedderProvider: "openrouter", + codebaseIndexEmbedderProvider: providerIdentifiers.openrouter, codebaseIndexEmbedderModelId: "mistralai/mistral-embed-2312", // Different 1024 dimension codebaseIndexQdrantUrl: "http://localhost:6333", }) diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index edcd8c22f4..627163f900 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -2,6 +2,7 @@ import { CodeIndexManager } from "../manager" import { CodeIndexServiceFactory } from "../service-factory" import type { MockedClass } from "vitest" import * as path from "path" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Helper: create a mock vscode.Uri from an fsPath function mockUri(fsPath: string, scheme = "file") { @@ -181,7 +182,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { isFeatureEnabled: true, getConfig: vi.fn().mockReturnValue({ isConfigured: true, - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-small", openAiOptions: { openAiNativeApiKey: "test-key" }, qdrantUrl: "http://localhost:6333", @@ -250,7 +251,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { isFeatureEnabled: true, getConfig: vi.fn().mockReturnValue({ isConfigured: true, - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-small", openAiOptions: { openAiNativeApiKey: "test-key" }, qdrantUrl: "http://localhost:6333", @@ -380,7 +381,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { isFeatureEnabled: true, getConfig: vitest.fn().mockReturnValue({ isConfigured: true, - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-small", openAiOptions: { openAiNativeApiKey: "test-key" }, qdrantUrl: "http://localhost:6333", @@ -477,7 +478,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { isFeatureEnabled: true, getConfig: vi.fn().mockReturnValue({ isConfigured: true, - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-small", openAiOptions: { openAiNativeApiKey: "test-key" }, qdrantUrl: "http://localhost:6333", @@ -584,7 +585,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { getGlobalState: vi.fn().mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://localhost:6333", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderModelId: "text-embedding-3-small", codebaseIndexEmbedderModelDimension: 1536, codebaseIndexSearchMaxResults: 10, diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index aafc198d85..aaf248d47a 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -38,6 +38,7 @@ const MockedQdrantVectorStore = QdrantVectorStore as MockedClass const mockGetModelDimension = getModelDimension as MockedFunction @@ -63,7 +64,7 @@ describe("CodeIndexServiceFactory", () => { // Arrange const testModelId = "text-embedding-3-large" const testConfig = { - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: testModelId, openAiOptions: { openAiNativeApiKey: "test-api-key", @@ -85,7 +86,7 @@ describe("CodeIndexServiceFactory", () => { // Arrange const testModelId = "nomic-embed-text:latest" const testConfig = { - embedderProvider: "ollama", + embedderProvider: providerIdentifiers.ollama, modelId: testModelId, ollamaOptions: { ollamaBaseUrl: "http://localhost:11434", @@ -106,7 +107,7 @@ describe("CodeIndexServiceFactory", () => { it("should handle undefined model ID for OpenAI embedder", () => { // Arrange const testConfig = { - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: undefined, openAiOptions: { openAiNativeApiKey: "test-api-key", @@ -127,7 +128,7 @@ describe("CodeIndexServiceFactory", () => { it("should handle undefined model ID for Ollama embedder", () => { // Arrange const testConfig = { - embedderProvider: "ollama", + embedderProvider: providerIdentifiers.ollama, modelId: undefined, ollamaOptions: { ollamaBaseUrl: "http://localhost:11434", @@ -148,7 +149,7 @@ describe("CodeIndexServiceFactory", () => { it("should throw error when OpenAI API key is missing", () => { // Arrange const testConfig = { - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-large", openAiOptions: { openAiNativeApiKey: undefined, @@ -163,7 +164,7 @@ describe("CodeIndexServiceFactory", () => { it("should throw error when Ollama base URL is missing", () => { // Arrange const testConfig = { - embedderProvider: "ollama", + embedderProvider: providerIdentifiers.ollama, modelId: "nomic-embed-text:latest", ollamaOptions: { ollamaBaseUrl: undefined, @@ -270,7 +271,7 @@ describe("CodeIndexServiceFactory", () => { it("should create GeminiEmbedder with default model when no modelId specified", () => { // Arrange const testConfig = { - embedderProvider: "gemini", + embedderProvider: providerIdentifiers.gemini, geminiOptions: { apiKey: "test-gemini-api-key", }, @@ -287,7 +288,7 @@ describe("CodeIndexServiceFactory", () => { it("should create GeminiEmbedder with specified modelId", () => { // Arrange const testConfig = { - embedderProvider: "gemini", + embedderProvider: providerIdentifiers.gemini, modelId: "gemini-embedding-001", geminiOptions: { apiKey: "test-gemini-api-key", @@ -306,7 +307,7 @@ describe("CodeIndexServiceFactory", () => { // Arrange - service-factory passes the config modelId directly; // GeminiEmbedder handles the migration internally const testConfig = { - embedderProvider: "gemini", + embedderProvider: providerIdentifiers.gemini, modelId: "text-embedding-004", geminiOptions: { apiKey: "test-gemini-api-key", @@ -324,7 +325,7 @@ describe("CodeIndexServiceFactory", () => { it("should throw error when Gemini API key is missing", () => { // Arrange const testConfig = { - embedderProvider: "gemini", + embedderProvider: providerIdentifiers.gemini, geminiOptions: { apiKey: undefined, }, @@ -338,7 +339,7 @@ describe("CodeIndexServiceFactory", () => { it("should throw error when Gemini options are missing", () => { // Arrange const testConfig = { - embedderProvider: "gemini", + embedderProvider: providerIdentifiers.gemini, geminiOptions: undefined, } mockConfigManager.getConfig.mockReturnValue(testConfig as any) @@ -381,7 +382,7 @@ describe("CodeIndexServiceFactory", () => { // Arrange const testModelId = "text-embedding-3-large" const testConfig = { - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: testModelId, qdrantUrl: "http://localhost:6333", qdrantApiKey: "test-key", @@ -406,7 +407,7 @@ describe("CodeIndexServiceFactory", () => { // Arrange const testModelId = "nomic-embed-text:latest" const testConfig = { - embedderProvider: "ollama", + embedderProvider: providerIdentifiers.ollama, modelId: testModelId, qdrantUrl: "http://localhost:6333", qdrantApiKey: "test-key", @@ -592,7 +593,7 @@ describe("CodeIndexServiceFactory", () => { it("should use model-specific dimension for Gemini provider", () => { // Arrange const testConfig = { - embedderProvider: "gemini", + embedderProvider: providerIdentifiers.gemini, modelId: "gemini-embedding-001", qdrantUrl: "http://localhost:6333", qdrantApiKey: "test-key", @@ -616,7 +617,7 @@ describe("CodeIndexServiceFactory", () => { it("should use default model dimension for Gemini when modelId not specified", () => { // Arrange const testConfig = { - embedderProvider: "gemini", + embedderProvider: providerIdentifiers.gemini, qdrantUrl: "http://localhost:6333", qdrantApiKey: "test-key", } @@ -641,7 +642,7 @@ describe("CodeIndexServiceFactory", () => { it("should use default model when config.modelId is undefined", () => { // Arrange const testConfig = { - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: undefined, qdrantUrl: "http://localhost:6333", qdrantApiKey: "test-key", @@ -665,7 +666,7 @@ describe("CodeIndexServiceFactory", () => { it("should throw error when vector dimension cannot be determined", () => { // Arrange const testConfig = { - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "unknown-model", qdrantUrl: "http://localhost:6333", qdrantApiKey: "test-key", @@ -680,7 +681,7 @@ describe("CodeIndexServiceFactory", () => { it("should throw error when Qdrant URL is missing", () => { // Arrange const testConfig = { - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-small", qdrantUrl: undefined, qdrantApiKey: "test-key", @@ -716,7 +717,7 @@ describe("CodeIndexServiceFactory", () => { it("should validate OpenAI embedder successfully", async () => { // Arrange const testConfig = { - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-small", openAiOptions: { openAiNativeApiKey: "test-api-key", @@ -740,7 +741,7 @@ describe("CodeIndexServiceFactory", () => { it("should return validation error from OpenAI embedder", async () => { // Arrange const testConfig = { - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-small", openAiOptions: { openAiNativeApiKey: "invalid-key", @@ -769,7 +770,7 @@ describe("CodeIndexServiceFactory", () => { it("should validate Ollama embedder successfully", async () => { // Arrange const testConfig = { - embedderProvider: "ollama", + embedderProvider: providerIdentifiers.ollama, modelId: "nomic-embed-text", ollamaOptions: { ollamaBaseUrl: "http://localhost:11434", @@ -818,7 +819,7 @@ describe("CodeIndexServiceFactory", () => { it("should validate Gemini embedder successfully", async () => { // Arrange const testConfig = { - embedderProvider: "gemini", + embedderProvider: providerIdentifiers.gemini, geminiOptions: { apiKey: "test-gemini-api-key", }, @@ -841,7 +842,7 @@ describe("CodeIndexServiceFactory", () => { it("should handle validation exceptions", async () => { // Arrange const testConfig = { - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-small", openAiOptions: { openAiNativeApiKey: "test-api-key", @@ -869,7 +870,7 @@ describe("CodeIndexServiceFactory", () => { it("should return error for invalid embedder configuration", async () => { // Arrange const testConfig = { - embedderProvider: "openai", + embedderProvider: providerIdentifiers.openai, modelId: "text-embedding-3-small", openAiOptions: { openAiNativeApiKey: undefined, // Missing API key diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index abac552561..dc6f72d34a 100644 --- a/src/services/code-index/config-manager.ts +++ b/src/services/code-index/config-manager.ts @@ -4,6 +4,7 @@ import { EmbedderProvider } from "./interfaces/manager" import { CodeIndexConfig, PreviousConfigSnapshot } from "./interfaces/config" import { DEFAULT_SEARCH_MIN_SCORE, DEFAULT_MAX_SEARCH_RESULTS } from "./constants" import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "../../shared/embeddingModels" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" /** * Manages configuration state and validation for the code indexing feature. @@ -11,7 +12,7 @@ import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from ".. */ export class CodeIndexConfigManager { private codebaseIndexEnabled: boolean = false - private embedderProvider: EmbedderProvider = "openai" + private embedderProvider: EmbedderProvider = providerIdentifiers.openai private modelId?: string private modelDimension?: number private openAiOptions?: ApiHandlerOptions @@ -48,7 +49,7 @@ export class CodeIndexConfigManager { const codebaseIndexConfig = this.contextProxy?.getGlobalState("codebaseIndexConfig") ?? { codebaseIndexEnabled: false, codebaseIndexQdrantUrl: "http://localhost:6333", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, codebaseIndexEmbedderBaseUrl: "", codebaseIndexEmbedderModelId: "", codebaseIndexSearchMinScore: undefined, @@ -106,24 +107,24 @@ export class CodeIndexConfigManager { this.openAiOptions = { openAiNativeApiKey: openAiKey } // Set embedder provider with support for openai-compatible - if (codebaseIndexEmbedderProvider === "ollama") { - this.embedderProvider = "ollama" + if (codebaseIndexEmbedderProvider === providerIdentifiers.ollama) { + this.embedderProvider = providerIdentifiers.ollama } else if (codebaseIndexEmbedderProvider === "openai-compatible") { this.embedderProvider = "openai-compatible" - } else if (codebaseIndexEmbedderProvider === "gemini") { - this.embedderProvider = "gemini" - } else if (codebaseIndexEmbedderProvider === "mistral") { - this.embedderProvider = "mistral" - } else if (codebaseIndexEmbedderProvider === "vercel-ai-gateway") { - this.embedderProvider = "vercel-ai-gateway" + } else if (codebaseIndexEmbedderProvider === providerIdentifiers.gemini) { + this.embedderProvider = providerIdentifiers.gemini + } else if (codebaseIndexEmbedderProvider === providerIdentifiers.mistral) { + this.embedderProvider = providerIdentifiers.mistral + } else if (codebaseIndexEmbedderProvider === providerIdentifiers.vercelAiGateway) { + this.embedderProvider = providerIdentifiers.vercelAiGateway } else if ((codebaseIndexEmbedderProvider as string) === "bedrock") { - this.embedderProvider = "bedrock" - } else if (codebaseIndexEmbedderProvider === "openrouter") { - this.embedderProvider = "openrouter" + this.embedderProvider = providerIdentifiers.bedrock + } else if (codebaseIndexEmbedderProvider === providerIdentifiers.openrouter) { + this.embedderProvider = providerIdentifiers.openrouter } else if (codebaseIndexEmbedderProvider === "semble") { this.embedderProvider = "semble" } else { - this.embedderProvider = "openai" + this.embedderProvider = providerIdentifiers.openai } this.modelId = codebaseIndexEmbedderModelId || undefined @@ -238,11 +239,11 @@ export class CodeIndexConfigManager { return true } - if (this.embedderProvider === "openai") { + if (this.embedderProvider === providerIdentifiers.openai) { const openAiKey = this.openAiOptions?.openAiNativeApiKey const qdrantUrl = this.qdrantUrl return !!(openAiKey && qdrantUrl) - } else if (this.embedderProvider === "ollama") { + } else if (this.embedderProvider === providerIdentifiers.ollama) { // Ollama model ID has a default, so only base URL is strictly required for config const ollamaBaseUrl = this.ollamaOptions?.ollamaBaseUrl const qdrantUrl = this.qdrantUrl @@ -253,28 +254,28 @@ export class CodeIndexConfigManager { const qdrantUrl = this.qdrantUrl const isConfigured = !!(baseUrl && apiKey && qdrantUrl) return isConfigured - } else if (this.embedderProvider === "gemini") { + } else if (this.embedderProvider === providerIdentifiers.gemini) { const apiKey = this.geminiOptions?.apiKey const qdrantUrl = this.qdrantUrl const isConfigured = !!(apiKey && qdrantUrl) return isConfigured - } else if (this.embedderProvider === "mistral") { + } else if (this.embedderProvider === providerIdentifiers.mistral) { const apiKey = this.mistralOptions?.apiKey const qdrantUrl = this.qdrantUrl const isConfigured = !!(apiKey && qdrantUrl) return isConfigured - } else if (this.embedderProvider === "vercel-ai-gateway") { + } else if (this.embedderProvider === providerIdentifiers.vercelAiGateway) { const apiKey = this.vercelAiGatewayOptions?.apiKey const qdrantUrl = this.qdrantUrl const isConfigured = !!(apiKey && qdrantUrl) return isConfigured - } else if (this.embedderProvider === "bedrock") { + } else if (this.embedderProvider === providerIdentifiers.bedrock) { // Only region is required for Bedrock (profile is optional) const region = this.bedrockOptions?.region const qdrantUrl = this.qdrantUrl const isConfigured = !!(region && qdrantUrl) return isConfigured - } else if (this.embedderProvider === "openrouter") { + } else if (this.embedderProvider === providerIdentifiers.openrouter) { const apiKey = this.openRouterOptions?.apiKey const qdrantUrl = this.qdrantUrl const isConfigured = !!(apiKey && qdrantUrl) diff --git a/src/services/code-index/embedders/__tests__/openrouter.spec.ts b/src/services/code-index/embedders/__tests__/openrouter.spec.ts index 088e9c7185..3f8d526e19 100644 --- a/src/services/code-index/embedders/__tests__/openrouter.spec.ts +++ b/src/services/code-index/embedders/__tests__/openrouter.spec.ts @@ -4,6 +4,7 @@ import { OpenAI } from "openai" import { OpenRouterEmbedder, OPENROUTER_DEFAULT_PROVIDER_NAME } from "../openrouter" import { getModelDimension, getDefaultModelId } from "../../../../shared/embeddingModels" import { clearAllMocks, restoreGlobals } from "../../../../test-utils/reset" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Mock the OpenAI SDK vi.mock("openai") @@ -340,7 +341,7 @@ describe("OpenRouterEmbedder", () => { }) it("should validate configuration with specificProvider", async () => { - const specificProvider = "openai" + const specificProvider = providerIdentifiers.openai const embedderWithProvider = new OpenRouterEmbedder(mockApiKey, undefined, undefined, specificProvider) const testEmbedding = new Float32Array([0.25, 0.5]) diff --git a/src/services/code-index/semble/__tests__/semble-downloader.spec.ts b/src/services/code-index/semble/__tests__/semble-downloader.spec.ts index 29fa8c1859..8a68da175f 100644 --- a/src/services/code-index/semble/__tests__/semble-downloader.spec.ts +++ b/src/services/code-index/semble/__tests__/semble-downloader.spec.ts @@ -97,6 +97,7 @@ import { downloadSemble, getSembleBinaryPath, SEMBLE_SHA256, + SEMBLE_MAX_ARCHIVE_BYTES, } from "../semble-downloader" import * as https from "https" import { spawn } from "child_process" @@ -111,6 +112,12 @@ describe("SEMBLE_SHA256 checksum fixture", () => { }) }) +describe("Semble archive download limit", () => { + it("allows 100 MiB for future release growth", () => { + expect(SEMBLE_MAX_ARCHIVE_BYTES).toBe(100 * 1024 * 1024) + }) +}) + describe("semble-downloader", () => { beforeEach(() => { clearAllMocks() diff --git a/src/services/code-index/semble/semble-downloader.ts b/src/services/code-index/semble/semble-downloader.ts index 5f68ffe58c..6a326b2150 100644 --- a/src/services/code-index/semble/semble-downloader.ts +++ b/src/services/code-index/semble/semble-downloader.ts @@ -26,7 +26,8 @@ const SEMBLE_ARCHIVES: Record = { export const SEMBLE_VERSION = "v0.4.1" const DOWNLOAD_BASE_URL = `https://github.com/Zoo-Code-Org/sembleexec/releases/download/${SEMBLE_VERSION}` const VERSION_FILE = ".semble-version" -const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 +// Leave room for future release growth while still rejecting anomalous downloads. +export const SEMBLE_MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 /** * SHA-256 checksums for each platform archive at SEMBLE_VERSION. @@ -122,7 +123,7 @@ export async function downloadSemble(storageDir: string): Promise verifyChecksum(archivePath, expectedChecksum), extractArchive: async (archivePath, stagingDir) => { diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index 96b6d80c90..beab32ad02 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -26,6 +26,7 @@ import { ICodeParser, IEmbedder, IFileWatcher, IVectorStore } from "./interfaces import { CodeIndexConfigManager } from "./config-manager" import { CacheManager } from "./cache-manager" import { BATCH_SEGMENT_THRESHOLD } from "./constants" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" /** * Factory class responsible for creating and configuring code indexing service dependencies. @@ -55,7 +56,7 @@ export class CodeIndexServiceFactory { ) } - if (provider === "openai") { + if (provider === providerIdentifiers.openai) { const apiKey = config.openAiOptions?.openAiNativeApiKey if (!apiKey) { @@ -65,7 +66,7 @@ export class CodeIndexServiceFactory { ...config.openAiOptions, openAiEmbeddingModelId: config.modelId, }) - } else if (provider === "ollama") { + } else if (provider === providerIdentifiers.ollama) { if (!config.ollamaOptions?.ollamaBaseUrl) { throw new Error(t("embeddings:serviceFactory.ollamaConfigMissing")) } @@ -82,28 +83,28 @@ export class CodeIndexServiceFactory { config.openAiCompatibleOptions.apiKey, config.modelId, ) - } else if (provider === "gemini") { + } else if (provider === providerIdentifiers.gemini) { if (!config.geminiOptions?.apiKey) { throw new Error(t("embeddings:serviceFactory.geminiConfigMissing")) } return new GeminiEmbedder(config.geminiOptions.apiKey, config.modelId) - } else if (provider === "mistral") { + } else if (provider === providerIdentifiers.mistral) { if (!config.mistralOptions?.apiKey) { throw new Error(t("embeddings:serviceFactory.mistralConfigMissing")) } return new MistralEmbedder(config.mistralOptions.apiKey, config.modelId) - } else if (provider === "vercel-ai-gateway") { + } else if (provider === providerIdentifiers.vercelAiGateway) { if (!config.vercelAiGatewayOptions?.apiKey) { throw new Error(t("embeddings:serviceFactory.vercelAiGatewayConfigMissing")) } return new VercelAiGatewayEmbedder(config.vercelAiGatewayOptions.apiKey, config.modelId) - } else if (provider === "bedrock") { + } else if (provider === providerIdentifiers.bedrock) { // Only region is required for Bedrock (profile is optional) if (!config.bedrockOptions?.region) { throw new Error(t("embeddings:serviceFactory.bedrockConfigMissing")) } return new BedrockEmbedder(config.bedrockOptions.region, config.bedrockOptions.profile, config.modelId) - } else if (provider === "openrouter") { + } else if (provider === providerIdentifiers.openrouter) { if (!config.openRouterOptions?.apiKey) { throw new Error(t("embeddings:serviceFactory.openRouterConfigMissing")) } diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index ace96c9fd0..865fa5cf51 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -79,7 +79,7 @@ describe("ProfileValidator", () => { providers: {}, } const profile: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "gpt-4", } @@ -107,7 +107,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "gpt-4", } @@ -122,7 +122,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "any-model-id", } @@ -137,7 +137,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, } expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(false) @@ -151,7 +151,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "gpt-4", } @@ -166,7 +166,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "gpt-4", } @@ -181,7 +181,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "gpt-4", } @@ -196,7 +196,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "gpt-4", } @@ -211,7 +211,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-opus", } @@ -226,7 +226,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "ollama", + apiProvider: providerIdentifiers.ollama, ollamaModelId: "llama3", } @@ -304,7 +304,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "lmstudio", + apiProvider: providerIdentifiers.lmstudio, lmStudioModelId: "lmstudio-model", } @@ -319,7 +319,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openrouter-model", } @@ -334,7 +334,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: "requesty", + apiProvider: providerIdentifiers.requesty, requestyModelId: "requesty-model", } @@ -361,7 +361,7 @@ describe("ProfileValidator", () => { providers: {}, } const profile: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "gpt-4", } diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts index 0363c27cdf..0f8b16f6cf 100644 --- a/src/shared/__tests__/api.spec.ts +++ b/src/shared/__tests__/api.spec.ts @@ -1,6 +1,7 @@ import { type ModelInfo, type ProviderSettings, ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" import { getModelMaxOutputTokens, shouldUseReasoningBudget, shouldUseReasoningEffort } from "../api" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" describe("getModelMaxOutputTokens", () => { const mockModel: ModelInfo = { @@ -11,7 +12,7 @@ describe("getModelMaxOutputTokens", () => { test("should return model maxTokens when maxTokens is within 20% of context window", () => { const settings: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, } // mockModel has maxTokens: 8192 and contextWindow: 200000 @@ -33,7 +34,7 @@ describe("getModelMaxOutputTokens", () => { } const settings: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, enableReasoningEffort: true, modelMaxTokens: 32000, } @@ -72,7 +73,7 @@ describe("getModelMaxOutputTokens", () => { } const settings: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, enableReasoningEffort: false, // Not using reasoning } @@ -93,7 +94,7 @@ describe("getModelMaxOutputTokens", () => { getModelMaxOutputTokens({ modelId: "claude-opus-4-7", model, - settings: { apiProvider: "anthropic", enableReasoningEffort: false }, + settings: { apiProvider: providerIdentifiers.anthropic, enableReasoningEffort: false }, }), ).toBe(ANTHROPIC_DEFAULT_MAX_TOKENS) @@ -101,7 +102,11 @@ describe("getModelMaxOutputTokens", () => { getModelMaxOutputTokens({ modelId: "claude-opus-4-7", model, - settings: { apiProvider: "anthropic", enableReasoningEffort: true, modelMaxTokens: 32_768 }, + settings: { + apiProvider: providerIdentifiers.anthropic, + enableReasoningEffort: true, + modelMaxTokens: 32_768, + }, }), ).toBe(32_768) }) @@ -122,7 +127,7 @@ describe("getModelMaxOutputTokens", () => { getModelMaxOutputTokens({ modelId: "claude-opus-4-8", model, - settings: { apiProvider: "anthropic", enableReasoningEffort: false }, + settings: { apiProvider: providerIdentifiers.anthropic, enableReasoningEffort: false }, }), ).toBe(ANTHROPIC_DEFAULT_MAX_TOKENS) @@ -130,7 +135,11 @@ describe("getModelMaxOutputTokens", () => { getModelMaxOutputTokens({ modelId: "claude-opus-4-8", model, - settings: { apiProvider: "anthropic", enableReasoningEffort: true, modelMaxTokens: 32_768 }, + settings: { + apiProvider: providerIdentifiers.anthropic, + enableReasoningEffort: true, + modelMaxTokens: 32_768, + }, }), ).toBe(32_768) }) @@ -149,7 +158,7 @@ describe("getModelMaxOutputTokens", () => { getModelMaxOutputTokens({ modelId: "claude-fable-5", model, - settings: { apiProvider: "anthropic", enableReasoningEffort: false }, + settings: { apiProvider: providerIdentifiers.anthropic, enableReasoningEffort: false }, }), ).toBe(ANTHROPIC_DEFAULT_MAX_TOKENS) @@ -157,7 +166,11 @@ describe("getModelMaxOutputTokens", () => { getModelMaxOutputTokens({ modelId: "claude-fable-5", model, - settings: { apiProvider: "anthropic", enableReasoningEffort: true, modelMaxTokens: 32_768 }, + settings: { + apiProvider: providerIdentifiers.anthropic, + enableReasoningEffort: true, + modelMaxTokens: 32_768, + }, }), ).toBe(32_768) }) @@ -176,7 +189,7 @@ describe("getModelMaxOutputTokens", () => { getModelMaxOutputTokens({ modelId: "claude-sonnet-5", model, - settings: { apiProvider: "anthropic", enableReasoningEffort: false }, + settings: { apiProvider: providerIdentifiers.anthropic, enableReasoningEffort: false }, }), ).toBe(ANTHROPIC_DEFAULT_MAX_TOKENS) @@ -184,7 +197,11 @@ describe("getModelMaxOutputTokens", () => { getModelMaxOutputTokens({ modelId: "claude-sonnet-5", model, - settings: { apiProvider: "anthropic", enableReasoningEffort: true, modelMaxTokens: 32_768 }, + settings: { + apiProvider: providerIdentifiers.anthropic, + enableReasoningEffort: true, + modelMaxTokens: 32_768, + }, }), ).toBe(32_768) }) @@ -203,7 +220,7 @@ describe("getModelMaxOutputTokens", () => { getModelMaxOutputTokens({ modelId: "claude-opus-5", model, - settings: { apiProvider: "anthropic", enableReasoningEffort: false }, + settings: { apiProvider: providerIdentifiers.anthropic, enableReasoningEffort: false }, }), ).toBe(ANTHROPIC_DEFAULT_MAX_TOKENS) @@ -211,7 +228,11 @@ describe("getModelMaxOutputTokens", () => { getModelMaxOutputTokens({ modelId: "claude-opus-5", model, - settings: { apiProvider: "anthropic", enableReasoningEffort: true, modelMaxTokens: 32_768 }, + settings: { + apiProvider: providerIdentifiers.anthropic, + enableReasoningEffort: true, + modelMaxTokens: 32_768, + }, }), ).toBe(32_768) }) @@ -226,7 +247,7 @@ describe("getModelMaxOutputTokens", () => { } const settings: ProviderSettings = { - apiProvider: "gemini", + apiProvider: providerIdentifiers.gemini, enableReasoningEffort: false, // Not using reasoning } @@ -242,7 +263,7 @@ describe("getModelMaxOutputTokens", () => { } const settings: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, } const result = getModelMaxOutputTokens({ @@ -263,7 +284,7 @@ describe("getModelMaxOutputTokens", () => { } const settings: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, } const result = getModelMaxOutputTokens({ @@ -283,7 +304,7 @@ describe("getModelMaxOutputTokens", () => { } const settings: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, } const result = getModelMaxOutputTokens({ @@ -303,7 +324,7 @@ describe("getModelMaxOutputTokens", () => { } const settings: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, } // Test various GPT-5 model IDs @@ -330,7 +351,7 @@ describe("getModelMaxOutputTokens", () => { } const settings: ProviderSettings = { - apiProvider: "zai", + apiProvider: providerIdentifiers.zai, modelMaxTokens: 64_000, // user override, above 20% of the context window (40k) } @@ -348,7 +369,7 @@ describe("getModelMaxOutputTokens", () => { } const settings: ProviderSettings = { - apiProvider: "zai", + apiProvider: providerIdentifiers.zai, modelMaxTokens: 999_999, // beyond the model ceiling } @@ -364,7 +385,7 @@ describe("getModelMaxOutputTokens", () => { } const settings: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, } // Test non-GPT-5 model IDs @@ -411,7 +432,7 @@ describe("getModelMaxOutputTokens", () => { const result = getModelMaxOutputTokens({ modelId: "gpt-5-turbo", model, - settings: { apiProvider: "openai" }, + settings: { apiProvider: providerIdentifiers.openai }, format: "openai", }) @@ -430,7 +451,7 @@ describe("getModelMaxOutputTokens", () => { const result = getModelMaxOutputTokens({ modelId: "glm-5.1", model, - settings: { apiProvider: "zai" }, + settings: { apiProvider: providerIdentifiers.zai }, format: "openai", }) @@ -447,7 +468,7 @@ describe("getModelMaxOutputTokens", () => { const result = getModelMaxOutputTokens({ modelId: "glm-5.1", model, - settings: { apiProvider: "openai" }, + settings: { apiProvider: providerIdentifiers.openai }, format: "openai", }) diff --git a/src/shared/__tests__/checkExistApiConfig.spec.ts b/src/shared/__tests__/checkExistApiConfig.spec.ts index ab92beea36..570e5c8223 100644 --- a/src/shared/__tests__/checkExistApiConfig.spec.ts +++ b/src/shared/__tests__/checkExistApiConfig.spec.ts @@ -1,6 +1,6 @@ // npx vitest run src/shared/__tests__/checkExistApiConfig.spec.ts -import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" +import { providerIdentifiers, retiredProviderIdentifiers, type ProviderSettings } from "@roo-code/types" import { checkExistKey } from "../checkExistApiConfig" @@ -61,7 +61,7 @@ describe("checkExistKey", () => { it("should return true for fake-ai provider without API key", () => { const config: ProviderSettings = { - apiProvider: "fake-ai", + apiProvider: providerIdentifiers.fakeAi, } expect(checkExistKey(config)).toBe(true) }) @@ -86,14 +86,14 @@ describe("checkExistKey", () => { it("should return false for roo provider without API key", () => { const config: ProviderSettings = { - apiProvider: "roo", + apiProvider: retiredProviderIdentifiers.roo, } expect(checkExistKey(config)).toBe(false) }) it("should return true for kimi-code provider with OAuth auth method", () => { const config: ProviderSettings = { - apiProvider: "kimi-code", + apiProvider: providerIdentifiers.kimiCode, kimiCodeAuthMethod: "oauth", } expect(checkExistKey(config)).toBe(true) @@ -105,14 +105,14 @@ describe("checkExistKey", () => { it("should return true for kimi-code provider without auth method (defaults to OAuth)", () => { const config: ProviderSettings = { - apiProvider: "kimi-code", + apiProvider: providerIdentifiers.kimiCode, } expect(checkExistKey(config)).toBe(true) }) it("should return true for kimi-code provider with api-key auth and key present", () => { const config: ProviderSettings = { - apiProvider: "kimi-code", + apiProvider: providerIdentifiers.kimiCode, kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "test-key", } @@ -121,7 +121,7 @@ describe("checkExistKey", () => { it("should return false for kimi-code provider with api-key auth but no key", () => { const config: ProviderSettings = { - apiProvider: "kimi-code", + apiProvider: providerIdentifiers.kimiCode, kimiCodeAuthMethod: "api-key", } expect(checkExistKey(config)).toBe(false) @@ -129,7 +129,7 @@ describe("checkExistKey", () => { it("should return false for zoo-gateway without session token or auth", () => { const config: ProviderSettings = { - apiProvider: "zoo-gateway", + apiProvider: providerIdentifiers.zooGateway, zooGatewayModelId: "alibaba/qwen-3.6-max-preview", } expect(checkExistKey(config)).toBe(false) @@ -142,7 +142,7 @@ describe("checkExistKey", () => { it("should return true for zoo-gateway when profile has zooSessionToken", () => { const config: ProviderSettings = { - apiProvider: "zoo-gateway", + apiProvider: providerIdentifiers.zooGateway, zooSessionToken: "zoo_ext_test_token", } expect(checkExistKey(config)).toBe(true) @@ -150,7 +150,7 @@ describe("checkExistKey", () => { it("should return true for zoo-gateway when Zoo Code session auth is active", () => { const config: ProviderSettings = { - apiProvider: "zoo-gateway", + apiProvider: providerIdentifiers.zooGateway, zooGatewayModelId: "alibaba/qwen-3.6-max-preview", } expect(checkExistKey(config, true)).toBe(true) @@ -158,7 +158,7 @@ describe("checkExistKey", () => { it("should ignore zooCodeIsAuthenticated for non-zoo-gateway providers", () => { const config: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, } expect(checkExistKey(config, true)).toBe(false) }) diff --git a/src/shared/api.ts b/src/shared/api.ts index 056612f9f9..1787e15e88 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -189,6 +189,7 @@ const dynamicProviderExtras = { moonshot: {} as { apiKey?: string; baseUrl?: string }, "opencode-go": {} as { apiKey?: string }, kenari: {} as { apiKey?: string }, + nanogpt: {} as { apiKey?: string }, "kimi-code": {} as { apiKey?: string }, } as const satisfies Record diff --git a/src/shared/embeddingModels.ts b/src/shared/embeddingModels.ts index a89e2c9488..3c59b681d2 100644 --- a/src/shared/embeddingModels.ts +++ b/src/shared/embeddingModels.ts @@ -3,6 +3,7 @@ */ import type { EmbedderProvider, EmbeddingModelProfiles } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Example profiles - expand this list as needed export const EMBEDDING_MODEL_PROFILES: EmbeddingModelProfiles = { @@ -157,11 +158,11 @@ export function getModelQueryPrefix(provider: EmbedderProvider, modelId: string) */ export function getDefaultModelId(provider: EmbedderProvider): string { switch (provider) { - case "openai": + case providerIdentifiers.openai: case "openai-compatible": return "text-embedding-3-small" - case "ollama": { + case providerIdentifiers.ollama: { // Choose a sensible default for Ollama, e.g., the first one listed or a specific one const ollamaModels = EMBEDDING_MODEL_PROFILES.ollama const defaultOllamaModel = ollamaModels && Object.keys(ollamaModels)[0] @@ -174,18 +175,18 @@ export function getDefaultModelId(provider: EmbedderProvider): string { return "unknown-default" // Placeholder specific model ID } - case "gemini": + case providerIdentifiers.gemini: return "gemini-embedding-001" - case "mistral": + case providerIdentifiers.mistral: return "codestral-embed-2505" - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return "openai/text-embedding-3-large" - case "bedrock": + case providerIdentifiers.bedrock: return "amazon.titan-embed-text-v2:0" - case "openrouter": + case providerIdentifiers.openrouter: return "openai/text-embedding-3-large" case "semble": diff --git a/src/shared/globalFileNames.ts b/src/shared/globalFileNames.ts index 0b54ff6809..9f15a06319 100644 --- a/src/shared/globalFileNames.ts +++ b/src/shared/globalFileNames.ts @@ -5,5 +5,5 @@ export const GlobalFileNames = { customModes: "custom_modes.yaml", taskMetadata: "task_metadata.json", historyItem: "history_item.json", - historyIndex: "_index.json", + delegationRepairIntent: "_delegation_repair_intent.json", } diff --git a/src/test-utils/api.ts b/src/test-utils/api.ts index 1939fbb7e8..d3381119e7 100644 --- a/src/test-utils/api.ts +++ b/src/test-utils/api.ts @@ -1,5 +1,6 @@ import { expect, vi, type Mock } from "vitest" +import type { ApiHandlerCreateMessageMetadata } from "../api" import type { ApiHandlerOptions } from "../shared/api" export function makeApiHandlerOptions(overrides: Partial = {}): ApiHandlerOptions { @@ -10,6 +11,19 @@ export function makeApiHandlerOptions(overrides: Partial = {} } } +/** + * Build request-message metadata for provider tests. Defaults a taskId so tests + * only pass the fields they care about (for example an abortSignal). + */ +export function makeCreateMessageMetadata( + overrides: Partial = {}, +): ApiHandlerCreateMessageMetadata { + return { + taskId: "test-task", + ...overrides, + } +} + export function mockOpenAiResponsesClient(create: Mock) { return { __esModule: true, diff --git a/src/utils/__tests__/autoImportSettings.spec.ts b/src/utils/__tests__/autoImportSettings.spec.ts index 80347cae2b..010cb273b6 100644 --- a/src/utils/__tests__/autoImportSettings.spec.ts +++ b/src/utils/__tests__/autoImportSettings.spec.ts @@ -77,6 +77,7 @@ import { autoImportSettings } from "../autoImportSettings" import * as vscode from "vscode" import fsPromises from "fs/promises" import { fileExistsAtPath } from "../fs" +import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" describe("autoImportSettings", () => { let mockProviderSettingsManager: any @@ -193,7 +194,7 @@ describe("autoImportSettings", () => { currentApiConfigName: "test-config", apiConfigs: { "test-config": { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, anthropicApiKey: "test-key", }, }, @@ -235,13 +236,13 @@ describe("autoImportSettings", () => { currentApiConfigName: "test-config", apiConfigs: { "test-config": { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, anthropicApiKey: "test-key", }, }, }, globalSettings: { - imageGenerationProvider: "roo", + imageGenerationProvider: retiredProviderIdentifiers.roo, customInstructions: "Test instructions", }, } diff --git a/src/utils/__tests__/enhance-prompt.spec.ts b/src/utils/__tests__/enhance-prompt.spec.ts index 7e8c702984..755e9df7d7 100644 --- a/src/utils/__tests__/enhance-prompt.spec.ts +++ b/src/utils/__tests__/enhance-prompt.spec.ts @@ -5,6 +5,7 @@ import type { ProviderSettings } from "@roo-code/types" import { singleCompletionHandler } from "../single-completion-handler" import { buildApiHandler, SingleCompletionHandler } from "../../api" import { supportPrompt } from "../../shared/support-prompt" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Mock the API handler vi.mock("../../api", () => ({ @@ -13,7 +14,7 @@ vi.mock("../../api", () => ({ describe("enhancePrompt", () => { const mockApiConfig: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiApiKey: "test-key", openAiBaseUrl: "https://api.openai.com/v1", enableReasoningEffort: false, @@ -98,7 +99,7 @@ describe("enhancePrompt", () => { it("uses appropriate model based on provider", async () => { const openRouterConfig: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "test-key", openRouterModelId: "test-model", enableReasoningEffort: false, diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index e060de4a31..79d08678a0 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -468,4 +468,78 @@ describe("safeWriteJson", () => { consoleErrorSpy.mockRestore() }) + + // Merge option tests + test("should merge incoming data with existing file content when merge callback is provided", async () => { + const initial = { a: 1, b: 2 } + await safeWriteJson(currentTestFilePath, initial) + + const incoming = { b: 3, c: 4 } + await safeWriteJson(currentTestFilePath, incoming, { + merge: (existing, data) => ({ + ...(existing as Record), + ...(data as Record), + }), + }) + + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual({ a: 1, b: 3, c: 4 }) + }) + + test("should pass null to merge callback when file does not exist", async () => { + const newFilePath = path.join(tempDir, "nonexistent.json") + const mergeFn = vi.fn((existing, incoming) => incoming) + + await safeWriteJson(newFilePath, { value: 42 }, { merge: mergeFn }) + + expect(mergeFn).toHaveBeenCalledWith(null, { value: 42 }) + const content = await readFileContent(newFilePath) + expect(content).toEqual({ value: 42 }) + }) + + test("should propagate non-ENOENT read errors during merge instead of silently losing data", async () => { + const initial = { a: 1, b: 2 } + await safeWriteJson(currentTestFilePath, initial) + + const eio = Object.assign(new Error("I/O error"), { code: "EIO" }) + vi.mocked(fs.readFile).mockRejectedValueOnce(eio) + + await expect( + safeWriteJson( + currentTestFilePath, + { b: 99 }, + { + merge: (existing, incoming) => ({ + ...(existing as Record), + ...(incoming as Record), + }), + }, + ), + ).rejects.toThrow("I/O error") + + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual({ a: 1, b: 2 }) + }) + + test("should treat corrupt JSON as null during merge", async () => { + await fs.writeFile(currentTestFilePath, "not valid json", "utf8") + + const mergeFn = vi.fn((_existing, incoming) => incoming) + await safeWriteJson(currentTestFilePath, { value: 1 }, { merge: mergeFn }) + + expect(mergeFn).toHaveBeenCalledWith(null, { value: 1 }) + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual({ value: 1 }) + }) + + test("should write incoming data directly when no merge callback is provided", async () => { + const initial = { a: 1, b: 2 } + await safeWriteJson(currentTestFilePath, initial) + + const replacement = { c: 3 } + await safeWriteJson(currentTestFilePath, replacement) + + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual({ c: 3 }) + }) }) diff --git a/src/utils/__tests__/shell.spec.ts b/src/utils/__tests__/shell.spec.ts index 4c45837680..a3e3bf4e18 100644 --- a/src/utils/__tests__/shell.spec.ts +++ b/src/utils/__tests__/shell.spec.ts @@ -3,25 +3,23 @@ import * as vscode from "vscode" import { existsSync } from "fs" import { userInfo } from "os" import { getShell } from "../shell" +import { BaseTerminal } from "../../integrations/terminal/BaseTerminal" +import { Terminal } from "../../integrations/terminal/Terminal" -// Mock vscode module vi.mock("vscode", () => ({ workspace: { getConfiguration: vi.fn(), }, })) -// Mock the os module vi.mock("os", () => ({ userInfo: vi.fn(() => ({ shell: null })), })) -// Mock the fs module — getWindowsShellFromVSCode probes for PowerShell 7 (pwsh.exe). vi.mock("fs", () => ({ existsSync: vi.fn(() => false), })) -// Mock path module for testing vi.mock("path", async () => { const actual = await vi.importActual("path") return { @@ -35,43 +33,63 @@ describe("Shell Detection Tests", () => { let originalEnv: NodeJS.ProcessEnv let originalGetConfig: any - // Helper to mock VS Code configuration + /** + * Stubs VS Code config using inspect() on the correct section names, matching + * how Terminal.getConfiguredDefaultProfileName and Terminal.getConfiguredProfiles + * read config (globalValue only — workspace excluded per APPLICATION scope). + */ function mockVsCodeConfig(platformKey: string, defaultProfileName: string | null, profiles: Record) { - vscode.workspace.getConfiguration = () => - ({ - get: (key: string) => { - if (key === `defaultProfile.${platformKey}`) { - return defaultProfileName - } - if (key === `profiles.${platformKey}`) { - return profiles - } - return undefined - }, - }) as any + vscode.workspace.getConfiguration = (section?: string) => { + if (section === "terminal.integrated") { + return { + inspect: (key: string) => { + expect(key).toBe(`defaultProfile.${platformKey}`) + return { + defaultValue: undefined, + globalValue: defaultProfileName ?? undefined, + } + }, + get: () => undefined, + } as any + } + if (section === "terminal.integrated.profiles") { + return { + inspect: (key: string) => { + expect(key).toBe(platformKey) + return { + defaultValue: undefined, + globalValue: profiles, + } + }, + get: () => undefined, + } as any + } + return { get: () => undefined, inspect: () => undefined } as any + } } beforeEach(() => { - // Store original references originalPlatform = process.platform originalEnv = { ...process.env } originalGetConfig = vscode.workspace.getConfiguration - // Clear environment variables for a clean test delete process.env.SHELL delete process.env.COMSPEC - // Reset userInfo mock to default vi.mocked(userInfo).mockReturnValue({ shell: null } as any) // Default: PowerShell 7 is not installed, so the probe falls back to legacy. vi.mocked(existsSync).mockReturnValue(false) + // Clear Zoo profile override and execa shell path between tests. + Terminal.setTerminalProfile(undefined) + BaseTerminal.setExecaShellPath(undefined) }) afterEach(() => { - // Restore everything Object.defineProperty(process, "platform", { value: originalPlatform }) process.env = originalEnv vscode.workspace.getConfiguration = originalGetConfig + Terminal.setTerminalProfile(undefined) + BaseTerminal.setExecaShellPath(undefined) vi.clearAllMocks() }) @@ -84,6 +102,7 @@ describe("Shell Detection Tests", () => { }) it("uses explicit PowerShell 7 path from VS Code config (profile path)", () => { + vi.mocked(existsSync).mockImplementation((p: any) => p === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") mockVsCodeConfig("windows", "PowerShell", { PowerShell: { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" }, }) @@ -91,65 +110,33 @@ describe("Shell Detection Tests", () => { }) it("should handle array path from VSCode terminal profile", () => { - // Mock VSCode configuration with array path - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.windows") return "PowerShell" - if (key === "profiles.windows") { - return { - PowerShell: { - // VSCode API may return path as an array - path: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe", "pwsh.exe"], - }, - } - } - return undefined - }), - } - - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - const result = getShell() - // Should use the first element of the array - expect(result).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") + vi.mocked(existsSync).mockImplementation((p: any) => p === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") + mockVsCodeConfig("windows", "PowerShell", { + PowerShell: { path: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe", "pwsh.exe"] }, + }) + expect(getShell()).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") }) - it("should handle empty array path and fall back to defaults", () => { - // Mock VSCode configuration with empty array path - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.windows") return "Custom" - if (key === "profiles.windows") { - return { - Custom: { - path: [], // Empty array - }, - } - } - return undefined - }), - } - - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - // Mock environment variable + it("falls through to COMSPEC when profile has an empty array path", () => { + mockVsCodeConfig("windows", "Custom", { + Custom: { path: [] }, + }) process.env.COMSPEC = "C:\\Windows\\System32\\cmd.exe" - - const result = getShell() - // Should fall back to cmd.exe - expect(result).toBe("C:\\Windows\\System32\\cmd.exe") + expect(getShell()).toBe("C:\\Windows\\System32\\cmd.exe") }) it("uses PowerShell 7 path if source is 'PowerShell' but no explicit path", () => { + vi.mocked(existsSync).mockImplementation((p: any) => p === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") mockVsCodeConfig("windows", "PowerShell", { PowerShell: { source: "PowerShell" }, }) expect(getShell()).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") }) - it("falls back to legacy PowerShell if profile includes 'powershell' but no path/source", () => { + it("falls back to legacy PowerShell if source is 'PowerShell' but PS7 is absent", () => { + vi.mocked(existsSync).mockReturnValue(false) mockVsCodeConfig("windows", "PowerShell", { - PowerShell: {}, + PowerShell: { source: "PowerShell" }, }) expect(getShell()).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") }) @@ -161,57 +148,48 @@ describe("Shell Detection Tests", () => { expect(getShell()).toBe("/bin/bash") }) - it("uses WSL bash when profile name includes 'wsl'", () => { - mockVsCodeConfig("windows", "Ubuntu WSL", { - "Ubuntu WSL": {}, - }) - expect(getShell()).toBe("/bin/bash") - }) - - it("defaults to cmd.exe if no special profile is matched", () => { + it("falls through to COMSPEC when profile has no path and no source", () => { + // A profile entry with no path and no recognised source is unresolvable; + // getShell falls through to env/fallback rather than guessing cmd.exe. mockVsCodeConfig("windows", "CommandPrompt", { CommandPrompt: {}, }) + process.env.COMSPEC = "C:\\Windows\\System32\\cmd.exe" expect(getShell()).toBe("C:\\Windows\\System32\\cmd.exe") }) - it("handles undefined profile gracefully", () => { - // Mock a case where defaultProfileName exists but the profile doesn't + it("falls through to COMSPEC when configured profile is missing from profiles map", () => { mockVsCodeConfig("windows", "NonexistentProfile", {}) + process.env.COMSPEC = "C:\\Windows\\System32\\cmd.exe" expect(getShell()).toBe("C:\\Windows\\System32\\cmd.exe") }) it("defaults to PowerShell 7 when no profile is configured and pwsh.exe is installed", () => { - // Modern VS Code launches PowerShell by default on Windows (issue #82) and - // prefers PS7 when present, so getShell() should report pwsh.exe. - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + // Modern VS Code prefers PS7 on Windows when no profile is explicitly set. + mockVsCodeConfig("windows", null, {}) vi.mocked(existsSync).mockReturnValue(true) - expect(getShell()).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") }) it("falls back to Windows PowerShell 5.1 when no profile is configured and PS7 is absent", () => { - // Without PS7 installed, the probe falls back to the always-present legacy - // PowerShell rather than cmd.exe. - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("windows", null, {}) vi.mocked(existsSync).mockReturnValue(false) - expect(getShell()).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") }) it("falls back to safe shell when the configured profile path is non-allowlisted", () => { + vi.mocked(existsSync).mockImplementation((p: any) => p === "C:\\Custom\\evil.exe") mockVsCodeConfig("windows", "Custom", { Custom: { path: "C:\\Custom\\evil.exe" }, }) - expect(getShell()).toBe("C:\\Windows\\System32\\cmd.exe") }) it("uses cmd.exe when a Command Prompt profile is explicitly configured", () => { + vi.mocked(existsSync).mockImplementation((p: any) => p === "C:\\Windows\\System32\\cmd.exe") mockVsCodeConfig("windows", "Command Prompt", { "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" }, }) - expect(getShell()).toBe("C:\\Windows\\System32\\cmd.exe") }) }) @@ -225,6 +203,7 @@ describe("Shell Detection Tests", () => { }) it("uses VS Code profile path if available", () => { + vi.mocked(existsSync).mockImplementation((p: any) => p === "/usr/local/bin/fish") mockVsCodeConfig("osx", "MyCustomShell", { MyCustomShell: { path: "/usr/local/bin/fish" }, }) @@ -232,42 +211,27 @@ describe("Shell Detection Tests", () => { }) it("should handle array path from VSCode terminal profile", () => { - // Mock VSCode configuration with array path - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.osx") return "zsh" - if (key === "profiles.osx") { - return { - zsh: { - path: ["/opt/homebrew/bin/zsh", "/bin/zsh"], - }, - } - } - return undefined - }), - } - - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - const result = getShell() - // Should use the first element of the array - expect(result).toBe("/opt/homebrew/bin/zsh") + vi.mocked(existsSync).mockImplementation((p: any) => p === "/opt/homebrew/bin/zsh") + mockVsCodeConfig("osx", "zsh", { + zsh: { path: ["/opt/homebrew/bin/zsh", "/bin/zsh"] }, + }) + expect(getShell()).toBe("/opt/homebrew/bin/zsh") }) it("falls back to userInfo().shell if no VS Code config is available", () => { - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("osx", null, {}) vi.mocked(userInfo).mockReturnValue({ shell: "/opt/homebrew/bin/zsh" } as any) expect(getShell()).toBe("/opt/homebrew/bin/zsh") }) it("falls back to SHELL env var if no userInfo shell is found", () => { - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("osx", null, {}) process.env.SHELL = "/usr/local/bin/zsh" expect(getShell()).toBe("/usr/local/bin/zsh") }) it("falls back to /bin/zsh if no config, userInfo, or env variable is set", () => { - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("osx", null, {}) expect(getShell()).toBe("/bin/zsh") }) }) @@ -281,6 +245,7 @@ describe("Shell Detection Tests", () => { }) it("uses VS Code profile path if available", () => { + vi.mocked(existsSync).mockImplementation((p: any) => p === "/usr/bin/fish") mockVsCodeConfig("linux", "CustomProfile", { CustomProfile: { path: "/usr/bin/fish" }, }) @@ -288,42 +253,27 @@ describe("Shell Detection Tests", () => { }) it("should handle array path from VSCode terminal profile", () => { - // Mock VSCode configuration with array path - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.linux") return "bash" - if (key === "profiles.linux") { - return { - bash: { - path: ["/usr/local/bin/bash", "/bin/bash"], - }, - } - } - return undefined - }), - } - - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - const result = getShell() - // Should use the first element of the array - expect(result).toBe("/usr/local/bin/bash") + vi.mocked(existsSync).mockImplementation((p: any) => p === "/usr/local/bin/bash") + mockVsCodeConfig("linux", "bash", { + bash: { path: ["/usr/local/bin/bash", "/bin/bash"] }, + }) + expect(getShell()).toBe("/usr/local/bin/bash") }) it("falls back to userInfo().shell if no VS Code config is available", () => { - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("linux", null, {}) vi.mocked(userInfo).mockReturnValue({ shell: "/usr/bin/zsh" } as any) expect(getShell()).toBe("/usr/bin/zsh") }) it("falls back to SHELL env var if no userInfo shell is found", () => { - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("linux", null, {}) process.env.SHELL = "/usr/bin/fish" expect(getShell()).toBe("/usr/bin/fish") }) it("falls back to /bin/bash if nothing is set", () => { - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("linux", null, {}) expect(getShell()).toBe("/bin/bash") }) }) @@ -334,7 +284,7 @@ describe("Shell Detection Tests", () => { describe("Unknown Platform / Error Handling", () => { it("falls back to /bin/bash for unknown platforms", () => { Object.defineProperty(process, "platform", { value: "sunos" }) - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("linux", null, {}) expect(getShell()).toBe("/bin/bash") }) @@ -349,7 +299,7 @@ describe("Shell Detection Tests", () => { it("handles userInfo errors gracefully, falling back to environment variable if present", () => { Object.defineProperty(process, "platform", { value: "darwin" }) - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("osx", null, {}) vi.mocked(userInfo).mockImplementation(() => { throw new Error("userInfo error") }) @@ -368,77 +318,79 @@ describe("Shell Detection Tests", () => { delete process.env.SHELL expect(getShell()).toBe("/bin/bash") }) + + it("handles inspect() returning undefined gracefully", () => { + Object.defineProperty(process, "platform", { value: "linux" }) + vscode.workspace.getConfiguration = () => + ({ + inspect: () => undefined, + get: () => undefined, + }) as any + expect(getShell()).toBe("/bin/bash") + }) }) // -------------------------------------------------------------------------- - // getTerminalConfig Behavior (tested via getShell) + // Scope isolation — workspace values must not influence getShell() // -------------------------------------------------------------------------- - describe("getTerminalConfig", () => { - it("returns defaultProfileName and matching profile for Windows", () => { + describe("Scope isolation (workspace values ignored)", () => { + it("Windows: ignores a workspace-scoped default profile", () => { Object.defineProperty(process, "platform", { value: "win32" }) - mockVsCodeConfig("windows", "Command Prompt", { - "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" }, - }) - expect(getShell()).toBe("C:\\Windows\\System32\\cmd.exe") - }) - - it("returns defaultProfileName and matching profile for macOS", () => { - Object.defineProperty(process, "platform", { value: "darwin" }) - mockVsCodeConfig("osx", "fish", { - fish: { path: "/usr/local/bin/fish" }, - }) - expect(getShell()).toBe("/usr/local/bin/fish") - }) - - it("returns defaultProfileName and matching profile for Linux", () => { - Object.defineProperty(process, "platform", { value: "linux" }) - mockVsCodeConfig("linux", "zsh", { - zsh: { path: "/usr/bin/zsh" }, - }) - expect(getShell()).toBe("/usr/bin/zsh") - }) - - it("returns null defaultProfileName when config value is undefined", () => { - Object.defineProperty(process, "platform", { value: "linux" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.linux") return undefined - if (key === "profiles.linux") return { bash: { path: "/bin/bash" } } - return undefined - }), + vi.mocked(existsSync).mockReturnValue(false) + // globalValue is undefined; only workspaceValue is set + vscode.workspace.getConfiguration = (section?: string) => { + if (section === "terminal.integrated") { + return { + inspect: (_key: string) => ({ + defaultValue: undefined, + globalValue: undefined, + workspaceValue: "PowerShell", + }), + get: () => undefined, + } as any + } + if (section === "terminal.integrated.profiles") { + return { + inspect: (_key: string) => ({ + defaultValue: undefined, + globalValue: undefined, + workspaceValue: { PowerShell: { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" } }, + }), + get: () => undefined, + } as any + } + return { get: () => undefined, inspect: () => undefined } as any } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - expect(getShell()).toBe("/bin/bash") + // No global profile → falls back to PS legacy (existsSync returns false for PS7) + expect(getShell()).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") }) - it("returns empty profiles when profiles config is null", () => { + it("Linux: ignores a workspace-scoped default profile", () => { Object.defineProperty(process, "platform", { value: "linux" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.linux") return "bash" - if (key === "profiles.linux") return null - return undefined - }), + vscode.workspace.getConfiguration = (section?: string) => { + if (section === "terminal.integrated") { + return { + inspect: (_key: string) => ({ + defaultValue: undefined, + globalValue: undefined, + workspaceValue: "CustomShell", + }), + get: () => undefined, + } as any + } + if (section === "terminal.integrated.profiles") { + return { + inspect: (_key: string) => ({ + defaultValue: undefined, + globalValue: undefined, + workspaceValue: { CustomShell: { path: "/usr/bin/fish" } }, + }), + get: () => undefined, + } as any + } + return { get: () => undefined, inspect: () => undefined } as any } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - expect(getShell()).toBe("/bin/bash") - }) - - it("returns fallback when getConfiguration throws", () => { - Object.defineProperty(process, "platform", { value: "darwin" }) - vi.mocked(vscode.workspace.getConfiguration).mockImplementation(() => { - throw new Error("config error") - }) - expect(getShell()).toBe("/bin/zsh") - }) - - it("returns fallback when config.get throws", () => { - Object.defineProperty(process, "platform", { value: "linux" }) - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ - get: () => { - throw new Error("get error") - }, - } as any) + // No global profile → falls through to /bin/bash expect(getShell()).toBe("/bin/bash") }) }) @@ -447,212 +399,26 @@ describe("Shell Detection Tests", () => { // Non-string defaultProfileName Handling // -------------------------------------------------------------------------- describe("Non-string defaultProfileName handling", () => { - it("Windows: handles numeric defaultProfileName without TypeError", () => { - Object.defineProperty(process, "platform", { value: "win32" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.windows") return 1 - if (key === "profiles.windows") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - vi.mocked(existsSync).mockReturnValue(false) + // Terminal.getConfiguredDefaultProfileName returns inspect().globalValue as-is. + // If VS Code somehow stores a non-string, it will be undefined (inspect returns + // typed as string | undefined), so getShell falls through to userInfo/env/fallback. - expect(getShell()).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") - }) - - it("Windows: handles boolean defaultProfileName without TypeError", () => { - Object.defineProperty(process, "platform", { value: "win32" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.windows") return true - if (key === "profiles.windows") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - vi.mocked(existsSync).mockReturnValue(false) - - expect(getShell()).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") - }) - - it("Windows: handles array defaultProfileName without TypeError", () => { + it("Windows: handles undefined defaultProfileName (no profile set)", () => { Object.defineProperty(process, "platform", { value: "win32" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.windows") return ["PowerShell"] - if (key === "profiles.windows") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + mockVsCodeConfig("windows", null, {}) vi.mocked(existsSync).mockReturnValue(false) - expect(getShell()).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") }) - it("Windows: handles object defaultProfileName without TypeError", () => { - Object.defineProperty(process, "platform", { value: "win32" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.windows") return { name: "PowerShell" } - if (key === "profiles.windows") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - vi.mocked(existsSync).mockReturnValue(false) - - expect(getShell()).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") - }) - - it("macOS: handles numeric defaultProfileName without TypeError", () => { - Object.defineProperty(process, "platform", { value: "darwin" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.osx") return 1 - if (key === "profiles.osx") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - expect(getShell()).toBe("/bin/zsh") - }) - - it("macOS: handles boolean defaultProfileName without TypeError", () => { - Object.defineProperty(process, "platform", { value: "darwin" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.osx") return true - if (key === "profiles.osx") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - expect(getShell()).toBe("/bin/zsh") - }) - - it("macOS: handles array defaultProfileName without TypeError", () => { - Object.defineProperty(process, "platform", { value: "darwin" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.osx") return ["zsh"] - if (key === "profiles.osx") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - expect(getShell()).toBe("/bin/zsh") - }) - - it("macOS: handles object defaultProfileName without TypeError", () => { - Object.defineProperty(process, "platform", { value: "darwin" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.osx") return {} - if (key === "profiles.osx") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - expect(getShell()).toBe("/bin/zsh") - }) - - // Mutation-resistant: without the typeof guard, profiles[1] === profiles["1"] in JS, - // so a numeric key that matches a real profile would return its path instead of falling back. - it("macOS: ignores numeric defaultProfileName even when it matches a profile key", () => { + it("macOS: returns fallback when no profile is configured", () => { Object.defineProperty(process, "platform", { value: "darwin" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.osx") return 1 - // Profile keyed as "1" — would be reached by profiles[1] if the guard were absent - if (key === "profiles.osx") return { "1": { path: "/usr/local/bin/zsh" } } - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - // Guard treats 1 as null → getMacShellFromVSCode returns null → fallback to /bin/zsh - // Without the guard it would return /usr/local/bin/zsh + mockVsCodeConfig("osx", null, {}) expect(getShell()).toBe("/bin/zsh") }) - it("Linux: handles numeric defaultProfileName without TypeError", () => { - Object.defineProperty(process, "platform", { value: "linux" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.linux") return 1 - if (key === "profiles.linux") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - expect(getShell()).toBe("/bin/bash") - }) - - it("Linux: handles boolean defaultProfileName without TypeError", () => { - Object.defineProperty(process, "platform", { value: "linux" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.linux") return true - if (key === "profiles.linux") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - expect(getShell()).toBe("/bin/bash") - }) - - it("Linux: handles array defaultProfileName without TypeError", () => { - Object.defineProperty(process, "platform", { value: "linux" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.linux") return ["bash"] - if (key === "profiles.linux") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - expect(getShell()).toBe("/bin/bash") - }) - - it("Linux: handles object defaultProfileName without TypeError", () => { - Object.defineProperty(process, "platform", { value: "linux" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.linux") return {} - if (key === "profiles.linux") return {} - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - expect(getShell()).toBe("/bin/bash") - }) - - // Mutation-resistant: same pattern as macOS — numeric key matches profile "1" only if unguarded. - it("Linux: ignores numeric defaultProfileName even when it matches a profile key", () => { + it("Linux: returns fallback when no profile is configured", () => { Object.defineProperty(process, "platform", { value: "linux" }) - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.linux") return 1 - // Profile keyed as "1" — would be reached by profiles[1] if the guard were absent - if (key === "profiles.linux") return { "1": { path: "/usr/bin/fish" } } - return undefined - }), - } - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - // Guard treats 1 as null → getLinuxShellFromVSCode returns null → fallback to /bin/bash - // Without the guard it would return /usr/bin/fish + mockVsCodeConfig("linux", null, {}) expect(getShell()).toBe("/bin/bash") }) }) @@ -663,6 +429,7 @@ describe("Shell Detection Tests", () => { describe("Shell Validation", () => { it("should allow common Windows shells", () => { Object.defineProperty(process, "platform", { value: "win32" }) + vi.mocked(existsSync).mockImplementation((p: any) => p === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") mockVsCodeConfig("windows", "PowerShell", { PowerShell: { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" }, }) @@ -671,6 +438,7 @@ describe("Shell Detection Tests", () => { it("should allow common Unix shells", () => { Object.defineProperty(process, "platform", { value: "linux" }) + vi.mocked(existsSync).mockImplementation((p: any) => p === "/usr/bin/fish") mockVsCodeConfig("linux", "CustomProfile", { CustomProfile: { path: "/usr/bin/fish" }, }) @@ -679,6 +447,7 @@ describe("Shell Detection Tests", () => { it("should handle case-insensitive matching on Windows", () => { Object.defineProperty(process, "platform", { value: "win32" }) + vi.mocked(existsSync).mockImplementation((p: any) => p === "c:\\windows\\system32\\cmd.exe") mockVsCodeConfig("windows", "PowerShell", { PowerShell: { path: "c:\\windows\\system32\\cmd.exe" }, }) @@ -687,90 +456,54 @@ describe("Shell Detection Tests", () => { it("should reject unknown shells and use fallback", () => { Object.defineProperty(process, "platform", { value: "linux" }) + vi.mocked(existsSync).mockImplementation((p: any) => p === "/usr/bin/malicious-shell") mockVsCodeConfig("linux", "CustomProfile", { CustomProfile: { path: "/usr/bin/malicious-shell" }, }) expect(getShell()).toBe("/bin/bash") }) - it("should validate array shell paths and use first allowed", () => { + it("should resolve array shell paths and use first path", () => { Object.defineProperty(process, "platform", { value: "win32" }) - - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.windows") return "PowerShell" - if (key === "profiles.windows") { - return { - PowerShell: { - path: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe", "pwsh"], - }, - } - } - return undefined - }), - } - - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - const result = getShell() - // Should return the first allowed shell from the array - expect(result).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") + vi.mocked(existsSync).mockImplementation((p: any) => p === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") + mockVsCodeConfig("windows", "PowerShell", { + PowerShell: { path: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe", "pwsh"] }, + }) + expect(getShell()).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") }) it("should reject non-allowed shell paths and fall back to safe defaults", () => { Object.defineProperty(process, "platform", { value: "win32" }) - - const mockConfig = { - get: vi.fn((key: string) => { - if (key === "defaultProfile.windows") return "Malicious" - if (key === "profiles.windows") { - return { - Malicious: { - path: "C:\\malicious\\shell.exe", - }, - } - } - return undefined - }), - } - - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) - - // Mock environment to provide a fallback + vi.mocked(existsSync).mockImplementation((p: any) => p === "C:\\malicious\\shell.exe") + mockVsCodeConfig("windows", "Malicious", { + Malicious: { path: "C:\\malicious\\shell.exe" }, + }) process.env.COMSPEC = "C:\\Windows\\System32\\cmd.exe" - - const result = getShell() - // Should fall back to safe default (cmd.exe) - expect(result).toBe("C:\\Windows\\System32\\cmd.exe") + expect(getShell()).toBe("C:\\Windows\\System32\\cmd.exe") }) it("should validate shells from VS Code config", () => { Object.defineProperty(process, "platform", { value: "darwin" }) + vi.mocked(existsSync).mockImplementation((p: any) => p === "/usr/local/bin/custom-shell") mockVsCodeConfig("osx", "MyCustomShell", { MyCustomShell: { path: "/usr/local/bin/custom-shell" }, }) - - const result = getShell() - expect(result).toBe("/bin/zsh") // macOS fallback + expect(getShell()).toBe("/bin/zsh") // not in allowlist → macOS fallback }) it("should validate shells from userInfo", () => { Object.defineProperty(process, "platform", { value: "linux" }) - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("linux", null, {}) vi.mocked(userInfo).mockReturnValue({ shell: "/usr/bin/evil-shell" } as any) - - const result = getShell() - expect(result).toBe("/bin/bash") // Linux fallback + expect(getShell()).toBe("/bin/bash") }) it("should validate shells from environment variables", () => { Object.defineProperty(process, "platform", { value: "linux" }) - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("linux", null, {}) vi.mocked(userInfo).mockReturnValue({ shell: null } as any) process.env.SHELL = "/opt/custom/shell" - - const result = getShell() - expect(result).toBe("/bin/bash") // Linux fallback + expect(getShell()).toBe("/bin/bash") }) it("should handle WSL bash correctly", () => { @@ -778,19 +511,88 @@ describe("Shell Detection Tests", () => { mockVsCodeConfig("windows", "WSL", { WSL: { source: "WSL" }, }) - - const result = getShell() - expect(result).toBe("/bin/bash") // Should be allowed + expect(getShell()).toBe("/bin/bash") }) it("should handle empty or null shell paths", () => { Object.defineProperty(process, "platform", { value: "linux" }) - vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any + mockVsCodeConfig("linux", null, {}) vi.mocked(userInfo).mockReturnValue({ shell: "" } as any) delete process.env.SHELL + expect(getShell()).toBe("/bin/bash") + }) + }) + + // -------------------------------------------------------------------------- + // Zoo profile override (Terminal.getProfileShell) takes precedence + // -------------------------------------------------------------------------- + describe("Zoo profile override", () => { + it("uses Zoo profile shell over VS Code default profile", () => { + Object.defineProperty(process, "platform", { value: "win32" }) + // VS Code default profile is PowerShell + vi.mocked(existsSync).mockReturnValue(true) + mockVsCodeConfig("windows", "PowerShell", { + PowerShell: { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" }, + }) + // Zoo profile override points to Git Bash + Terminal.setTerminalProfile("Git Bash") + vi.spyOn(Terminal, "getProfileShell").mockReturnValue({ + shellPath: "C:\\Program Files\\Git\\bin\\bash.exe", + }) + expect(getShell()).toBe("C:\\Program Files\\Git\\bin\\bash.exe") + }) - const result = getShell() - expect(result).toBe("/bin/bash") // Should fall back to safe default + it("falls through to VS Code default when Zoo profile has no resolvable shell", () => { + Object.defineProperty(process, "platform", { value: "win32" }) + vi.mocked(existsSync).mockImplementation((p) => String(p) === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") + mockVsCodeConfig("windows", "PowerShell", { + PowerShell: { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" }, + }) + Terminal.setTerminalProfile("Unresolvable") + vi.spyOn(Terminal, "getProfileShell").mockReturnValue(undefined) + expect(getShell()).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") + }) + }) + + // -------------------------------------------------------------------------- + // Explicit execa shell path takes highest precedence + // -------------------------------------------------------------------------- + describe("Execa shell path override", () => { + it("uses explicit execa shell path over VS Code config", () => { + Object.defineProperty(process, "platform", { value: "win32" }) + vi.mocked(existsSync).mockReturnValue(true) + mockVsCodeConfig("windows", "PowerShell", { + PowerShell: { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" }, + }) + BaseTerminal.setExecaShellPath("C:\\Program Files\\Git\\bin\\bash.exe") + expect(getShell()).toBe("C:\\Program Files\\Git\\bin\\bash.exe") + }) + + it("uses explicit execa shell path over Zoo profile override", () => { + Object.defineProperty(process, "platform", { value: "linux" }) + mockVsCodeConfig("linux", null, {}) + Terminal.setTerminalProfile("fish") + vi.spyOn(Terminal, "getProfileShell").mockReturnValue({ + shellPath: "/usr/bin/fish", + }) + BaseTerminal.setExecaShellPath("/bin/zsh") + expect(getShell()).toBe("/bin/zsh") + }) + + it("falls through to VS Code config when execa shell path is not set", () => { + Object.defineProperty(process, "platform", { value: "linux" }) + vi.mocked(existsSync).mockImplementation((p) => String(p) === "/usr/bin/fish") + mockVsCodeConfig("linux", "fish", { + fish: { path: "/usr/bin/fish" }, + }) + expect(getShell()).toBe("/usr/bin/fish") + }) + + it("rejects non-allowlisted execa shell path and uses fallback", () => { + Object.defineProperty(process, "platform", { value: "linux" }) + mockVsCodeConfig("linux", null, {}) + BaseTerminal.setExecaShellPath("/opt/evil/shell") + expect(getShell()).toBe("/bin/bash") }) }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index c32dd92ce5..957a0bb20f 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -15,6 +15,16 @@ export interface SafeWriteJsonOptions { * @default false */ prettyPrint?: boolean + + /** + * When provided, the current file is read under the advisory lock + * and passed to this function along with the incoming data. The + * return value replaces `data` for the write. This turns a blind + * overwrite into an atomic read-modify-write, preventing cross-process + * lost updates. `existing` is null when the file does not exist or + * cannot be parsed. + */ + merge?: (existing: unknown, incoming: unknown) => unknown } /** @@ -54,7 +64,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Acquire the lock before any file operations try { releaseLock = await lockfile.lock(absoluteFilePath, { - stale: 31000, // Stale after 31 seconds + stale: LOCK_STALE_MS, update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long realpath: false, // the file may not exist yet, which is acceptable retries: { @@ -83,6 +93,23 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso let actualTempBackupFilePath: string | null = null try { + // If a merge callback was provided, read the current file under the lock + // and let the caller merge before we write. Must be inside try/finally + // so a throwing merge still releases the lock. + if (options?.merge) { + let existing: unknown = null + try { + existing = JSON.parse(await fs.readFile(absoluteFilePath, "utf8")) + } catch (error: unknown) { + const code = + error && typeof error === "object" && "code" in error ? (error as { code: string }).code : undefined + if (!(error instanceof SyntaxError) && code !== "ENOENT") { + throw error + } + } + data = options.merge(existing, data) + } + // Step 1: Write data to a new temporary file. actualTempNewFilePath = path.join( path.dirname(absoluteFilePath), @@ -220,4 +247,6 @@ async function _streamDataToFile(targetPath: string, data: any, prettyPrint = fa }) } +export const LOCK_STALE_MS = 31_000 + export { safeWriteJson } diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 31aa0b0fa1..6bc8383b25 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -3,6 +3,9 @@ import { existsSync } from "fs" import { userInfo } from "os" import * as path from "path" +import { BaseTerminal } from "../integrations/terminal/BaseTerminal" +import { Terminal } from "../integrations/terminal/Terminal" + // Security: Allowlist of approved shell executables to prevent arbitrary command execution const SHELL_ALLOWLIST = new Set([ // Windows PowerShell variants @@ -113,143 +116,70 @@ const SHELL_PATHS = { FALLBACK: "/bin/sh", } as const -interface MacTerminalProfile { - path?: string | string[] -} - -type MacTerminalProfiles = Record - -interface WindowsTerminalProfile { - path?: string | string[] - source?: "PowerShell" | "WSL" -} - -type WindowsTerminalProfiles = Record - -interface LinuxTerminalProfile { - path?: string | string[] -} - -type LinuxTerminalProfiles = Record - // ----------------------------------------------------- -// 1) VS Code Terminal Configuration Helpers +// 1) VS Code Terminal Configuration Helper // ----------------------------------------------------- -type PlatformProfilesMap = { - windows: WindowsTerminalProfiles - osx: MacTerminalProfiles - linux: LinuxTerminalProfiles +function preferredWindowsPowerShell(): string { + return existsSync(SHELL_PATHS.POWERSHELL_7) ? SHELL_PATHS.POWERSHELL_7 : SHELL_PATHS.POWERSHELL_LEGACY } /** - * Reads the VS Code terminal profile configuration for the given platform. - * - * The key must be one of `"windows"`, `"osx"`, or `"linux"` — the exact strings - * VS Code uses in `terminal.integrated.defaultProfile.` and - * `terminal.integrated.profiles.`. Passing any other value is a compile-time - * error, which prevents silent mismatches such as `"darwin"` returning empty config. + * Resolves the shell that VS Code's integrated terminal will use, matching the + * priority order from Terminal constructor: Zoo profile override first, then + * VS Code's configured default profile (trusted scopes only — workspace scope + * excluded per APPLICATION scope restriction). * - * The return type (`defaultProfileName` and `profiles`) is inferred from `K` via - * `PlatformProfilesMap`, so callers don't need an explicit type parameter. - * - * Returns `{ defaultProfileName: null, profiles: {} }` on any VS Code API error. + * Returns null when no profile is configured or the profile has no resolvable path. */ -function getTerminalConfig( - platformKey: K, -): { defaultProfileName: string | null; profiles: PlatformProfilesMap[K] } { +function getShellFromVSCode(): string | null { try { - const config = vscode.workspace.getConfiguration("terminal.integrated") - const rawProfileName = config.get(`defaultProfile.${platformKey}`) - const defaultProfileName = typeof rawProfileName === "string" ? rawProfileName : null - const profiles = config.get(`profiles.${platformKey}`) ?? ({} as PlatformProfilesMap[K]) - return { defaultProfileName, profiles } - } catch { - return { defaultProfileName: null, profiles: {} as PlatformProfilesMap[K] } - } -} + // Zoo profile override takes precedence — this is the same path + // Terminal constructor uses to set shellPath on createTerminal(). + const profileShell = Terminal.getProfileShell() + if (profileShell?.shellPath) { + return profileShell.shellPath + } -// ----------------------------------------------------- -// 2) Platform-Specific VS Code Shell Retrieval -// ----------------------------------------------------- + const profileName = Terminal.getConfiguredDefaultProfileName() -/** - * Normalizes a path that can be either a string or an array of strings. - * If it's an array, returns the first element. Otherwise returns the string. - */ -function normalizeShellPath(path: string | string[] | undefined): string | null { - if (!path) return null - if (Array.isArray(path)) { - return path.length > 0 ? path[0] : null - } - return path -} + if (!profileName) { + // No profile configured at user/default scope. On Windows, VS Code + // auto-detects and prefers PowerShell 7 when installed. Mirror that so + // the system prompt matches what VS Code will actually open. (issue #82) + if (process.platform === "win32") { + return preferredWindowsPowerShell() + } + return null + } -/** Attempts to retrieve a shell path from VS Code config on Windows. */ -function getWindowsShellFromVSCode(): string | null { - const { defaultProfileName, profiles } = getTerminalConfig("windows") - if (!defaultProfileName) { - // No explicit Windows terminal profile is configured. VS Code auto-detects - // the default on modern Windows and prefers PowerShell 7 (pwsh.exe) when it - // is installed, otherwise the always-present Windows PowerShell 5.1. Mirror - // that here so the system prompt advertises the real shell instead of falling - // through to COMSPEC (cmd.exe). See issue #82. - return existsSync(SHELL_PATHS.POWERSHELL_7) ? SHELL_PATHS.POWERSHELL_7 : SHELL_PATHS.POWERSHELL_LEGACY - } + const profiles = Terminal.getConfiguredProfiles() + const profile = profiles[profileName] as { path?: unknown; source?: unknown } | null | undefined - const profile = profiles[defaultProfileName] - - // If the profile name indicates PowerShell, do version-based detection. - // In testing it was found these typically do not have a path, and this - // implementation manages to deductively get the correct version of PowerShell - if (defaultProfileName.toLowerCase().includes("powershell")) { - const normalizedPath = normalizeShellPath(profile?.path) - if (normalizedPath) { - // If there's an explicit PowerShell path, return that - return normalizedPath - } else if (profile?.source === "PowerShell") { - // If the profile is sourced from PowerShell, assume the newest - return SHELL_PATHS.POWERSHELL_7 + if (!profile) { + return null } - // Otherwise, assume legacy Windows PowerShell - return SHELL_PATHS.POWERSHELL_LEGACY - } - // If there's a specific path, return that immediately - const normalizedPath = normalizeShellPath(profile?.path) - if (normalizedPath) { - return normalizedPath - } + const resolved = Terminal.resolveProfilePath(profile.path) + if (resolved) { + return resolved + } - // If the profile indicates WSL - if (profile?.source === "WSL" || defaultProfileName.toLowerCase().includes("wsl")) { - return SHELL_PATHS.WSL_BASH - } + // source-only PowerShell profiles (e.g. { source: "PowerShell" }) have no + // path but we can still identify the shell type from the source field. + if (typeof profile.source === "string" && profile.source.toLowerCase().includes("powershell")) { + return preferredWindowsPowerShell() + } - // If nothing special detected, we assume cmd - return SHELL_PATHS.CMD -} + // source-only WSL profiles + if (typeof profile.source === "string" && profile.source.toLowerCase().includes("wsl")) { + return SHELL_PATHS.WSL_BASH + } -/** Attempts to retrieve a shell path from VS Code config on macOS. */ -function getMacShellFromVSCode(): string | null { - const { defaultProfileName, profiles } = getTerminalConfig("osx") - if (!defaultProfileName) { return null - } - - const profile = profiles[defaultProfileName] - return normalizeShellPath(profile?.path) -} - -/** Attempts to retrieve a shell path from VS Code config on Linux. */ -function getLinuxShellFromVSCode(): string | null { - const { defaultProfileName, profiles } = getTerminalConfig("linux") - if (!defaultProfileName) { + } catch { return null } - - const profile = profiles[defaultProfileName] - return normalizeShellPath(profile?.path) } // ----------------------------------------------------- @@ -340,34 +270,31 @@ function getSafeFallbackShell(): string { export function getShell(): string { let shell: string | null = null - // 1. Check VS Code config first. - if (process.platform === "win32") { - // Special logic for Windows - shell = getWindowsShellFromVSCode() - } else if (process.platform === "darwin") { - // macOS from VS Code - shell = getMacShellFromVSCode() - } else if (process.platform === "linux") { - // Linux from VS Code - shell = getLinuxShellFromVSCode() + // 1. Explicit execa shell path — when set, execa uses this exact executable + // regardless of VS Code profile settings. + shell = BaseTerminal.getExecaShellPath() ?? null + + // 2. VS Code profile config (Zoo override first, then default profile). + if (!shell) { + shell = getShellFromVSCode() } - // 2. If no shell from VS Code, try userInfo() + // 3. If no shell from VS Code, try userInfo() if (!shell) { shell = getShellFromUserInfo() } - // 3. If still nothing, try environment variable + // 4. If still nothing, try environment variable if (!shell) { shell = getShellFromEnv() } - // 4. Finally, fall back to a default + // 5. Finally, fall back to a default if (!shell) { shell = getSafeFallbackShell() } - // 5. Validate the shell against allowlist + // 6. Validate the shell against allowlist if (!isShellAllowed(shell)) { shell = getSafeFallbackShell() } diff --git a/src/vitest.config.ts b/src/vitest.config.ts index c0c8310e24..42986c454c 100644 --- a/src/vitest.config.ts +++ b/src/vitest.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "lcov"], - include: ["src/**/*.ts", "src/**/*.tsx"], + include: ["src/**/*.ts", "src/**/*.tsx", "eslint-rules/**/*.mjs"], exclude: [ "**/*.test.ts", "**/*.test.tsx", diff --git a/webview-ui/AGENTS.md b/webview-ui/AGENTS.md index 892c76676a..1c74d22109 100644 --- a/webview-ui/AGENTS.md +++ b/webview-ui/AGENTS.md @@ -68,6 +68,13 @@ Skip a visual test when the change is behavior-only (state transitions, handler - Update intentional baselines with `pnpm test:visual:docker:update` and commit the resulting `__screenshots__` files with the UI change. - Use the Docker commands when creating or reviewing baselines; host-rendered screenshots are not the source of truth. - If Docker is unavailable, `pnpm test:visual` can help diagnose test code, but do not create or update committed baselines from the host rendering environment. +- If Docker cannot run at all, use the repository's pinned GitHub Actions container as the authoritative baseline generator: + 1. Push the visual test without new or updated host-generated baselines. + 2. Dispatch `.github/workflows/visual-regression.yml` against that branch. The expected missing-baseline failure uploads the `webview-visual-regression` artifact. + 3. Download the artifact and copy each relevant `test-results/**/-actual.png` to the test's `__screenshots__/.png` path. + 4. Commit those container-generated PNGs, push, and rerun the workflow until the visual job passes. +- Fork contributors can use this fallback in their own fork when GitHub Actions is enabled and the workflow exists on the fork's default branch, for example with `gh workflow run visual-regression.yml --repo /Zoo-Code --ref `. The public Playwright image and artifact upload do not require upstream secrets, though the Codecov upload may be unavailable. Fork contributors usually cannot manually dispatch the upstream repository's workflow; a maintainer can run it against an upstream branch when needed. +- The files under `playwright/themes/` are generated from the resolved webview variables exposed by the VS Code version pinned in `apps/vscode-e2e/package.json`; do not edit them manually. On Linux, update them with `xvfb-run -a pnpm --filter @roo-code/vscode-e2e themes:update` and verify them with `xvfb-run -a pnpm --filter @roo-code/vscode-e2e themes:check`. CI runs the same check and fails when the checked-in fixtures drift from the pinned VS Code runtime. - Keep visual tests limited to components supported by the current Playwright harness. Add shared extension state, translation, React Query, or other provider support before snapshotting components that require it. - The current baseline naming assumes a single Chromium project. Include `{projectName}` in `snapshotPathTemplate` before adding another browser project. - Import `test` and `expect` from `webview-ui/playwright/coverage-fixture.ts` (not directly from `@playwright/experimental-ct-react`) so the auto-fixture collects V8 coverage for `monocart-reporter` — that's what produces `coverage-ct/lcov.info` for the Codecov upload. diff --git a/webview-ui/eslint-suppressions.json b/webview-ui/eslint-suppressions.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/webview-ui/eslint-suppressions.json @@ -0,0 +1 @@ +{} diff --git a/webview-ui/playwright-ct.config.ts b/webview-ui/playwright-ct.config.ts index 3eb0abac7b..4d43820d5a 100644 --- a/webview-ui/playwright-ct.config.ts +++ b/webview-ui/playwright-ct.config.ts @@ -7,6 +7,13 @@ import react from "@vitejs/plugin-react" import tailwindcss from "@tailwindcss/vite" const dirname = path.dirname(fileURLToPath(import.meta.url)) +const rooCodeTypesShim = path.resolve(dirname, "./playwright/roo-code-types.ts") +const rooCodeTypesShimImporters = [ + "/src/shared/modes.ts", + "/webview-ui/src/components/chat/CodeIndexPopover.tsx", + "/webview-ui/src/components/chat/ModeSelector.tsx", + "/webview-ui/src/components/settings/UISettings.tsx", +] const monocartReporter: ReporterDescription = [ "monocart-reporter", @@ -49,6 +56,19 @@ export default defineConfig({ ctTemplateDir: "./playwright", ctViteConfig: { plugins: [ + { + name: "playwright-ct-roo-code-types-shim", + enforce: "pre", + resolveId(source, importer) { + if ( + source === "@roo-code/types" && + importer && + rooCodeTypesShimImporters.some((suffix) => importer.endsWith(suffix)) + ) { + return rooCodeTypesShim + } + }, + }, react({ babel: { plugins: [["babel-plugin-react-compiler", { target: "18" }]], @@ -57,17 +77,28 @@ export default defineConfig({ tailwindcss(), ], resolve: { - alias: { - "@src/i18n/TranslationContext": path.resolve(dirname, "./playwright/TranslationContext.ts"), - "@": path.resolve(dirname, "./src"), - "@src": path.resolve(dirname, "./src"), - "@roo": path.resolve(dirname, "../src/shared"), - "@vscode/webview-ui-toolkit/react": path.resolve( - dirname, - "./src/__mocks__/@vscode/webview-ui-toolkit/react.tsx", - ), - vscode: path.resolve(dirname, "../src/__mocks__/vscode.js"), - }, + alias: [ + { + find: "@/context/ExtensionStateContext", + replacement: path.resolve(dirname, "./playwright/ExtensionStateContext.tsx"), + }, + { + find: "@src/context/ExtensionStateContext", + replacement: path.resolve(dirname, "./playwright/ExtensionStateContext.tsx"), + }, + { + find: "@src/i18n/TranslationContext", + replacement: path.resolve(dirname, "./playwright/TranslationContext.ts"), + }, + { find: "@", replacement: path.resolve(dirname, "./src") }, + { find: "@src", replacement: path.resolve(dirname, "./src") }, + { find: "@roo", replacement: path.resolve(dirname, "../src/shared") }, + { + find: "@vscode/webview-ui-toolkit/react", + replacement: path.resolve(dirname, "./src/__mocks__/@vscode/webview-ui-toolkit/react.tsx"), + }, + { find: "vscode", replacement: path.resolve(dirname, "../src/__mocks__/vscode.js") }, + ], }, define: { "process.platform": JSON.stringify(process.platform), diff --git a/webview-ui/playwright/AppProviders.tsx b/webview-ui/playwright/AppProviders.tsx new file mode 100644 index 0000000000..40f33896dd --- /dev/null +++ b/webview-ui/playwright/AppProviders.tsx @@ -0,0 +1,57 @@ +import React, { useState } from "react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext" +import { TranslationProvider } from "@/i18n/TranslationContext" +import i18next, { loadTranslations } from "@/i18n/setup" +import { TooltipProvider } from "@/components/ui/tooltip" +import { TranslationContext as PlaywrightTranslationContext } from "@src/i18n/TranslationContext" + +loadTranslations() + +type InitialState = NonNullable["initialState"]> + +interface AppProvidersProps { + children: React.ReactNode + initialState?: InitialState +} + +const defaultInitialState: InitialState = { + language: "en", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + telemetrySetting: "enabled", + apiConfiguration: { apiProvider: "anthropic" }, + currentApiConfigName: "Default", + listApiConfigMeta: [{ id: "default", name: "Default", modelId: "claude-sonnet" }], + pinnedApiConfigs: {}, + hasOpenedModeSelector: true, +} + +export function AppProviders({ children, initialState }: AppProvidersProps) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }), + ) + + return ( + + + i18next.t(key, options), i18n: i18next }}> + + +

+
+ {children} +
+ + + + + + ) +} diff --git a/webview-ui/playwright/ExtensionStateContext.tsx b/webview-ui/playwright/ExtensionStateContext.tsx new file mode 100644 index 0000000000..3fab6a74f9 --- /dev/null +++ b/webview-ui/playwright/ExtensionStateContext.tsx @@ -0,0 +1,51 @@ +import React, { createContext, useContext, useMemo, useState } from "react" + +const noop = () => undefined + +const defaultState = { + language: "en", + clineMessages: [], + taskHistory: [], + filePaths: [], + openedTabs: [], + commands: [], + customModes: [], + customModePrompts: {}, + currentApiConfigName: "Default", + listApiConfigMeta: [{ id: "default", name: "Default", modelId: "claude-sonnet" }], + pinnedApiConfigs: {}, + apiConfiguration: { apiProvider: "anthropic" }, + enterBehavior: "send", + lockApiConfigAcrossModes: false, + telemetrySetting: "enabled", + autoApprovalEnabled: false, + togglePinnedApiConfig: noop, + setHasOpenedModeSelector: noop, + setApiConfiguration: noop, + setAutoApprovalEnabled: noop, +} + +export const ExtensionStateContext = createContext>(defaultState) + +export function ExtensionStateContextProvider({ + children, + initialState, +}: { + children: React.ReactNode + initialState?: Record +}) { + const initialAutoApprovalEnabled = initialState?.autoApprovalEnabled + const [autoApprovalEnabled, setAutoApprovalEnabled] = useState( + typeof initialAutoApprovalEnabled === "boolean" ? initialAutoApprovalEnabled : defaultState.autoApprovalEnabled, + ) + const value = useMemo( + () => ({ ...defaultState, ...initialState, autoApprovalEnabled, setAutoApprovalEnabled }), + [autoApprovalEnabled, initialState], + ) + + return ( + {children} + ) +} + +export const useExtensionState = () => useContext(ExtensionStateContext) diff --git a/webview-ui/playwright/contrast.ts b/webview-ui/playwright/contrast.ts new file mode 100644 index 0000000000..faef445c9e --- /dev/null +++ b/webview-ui/playwright/contrast.ts @@ -0,0 +1,200 @@ +import type { Locator } from "@playwright/test" +import { expect } from "@playwright/test" + +export interface RgbaColor { + r: number + g: number + b: number + a: number +} + +export type ContrastProperty = "color" | "background-color" | "border-color" | "outline-color" | "fill" | "stroke" + +interface ContrastOptions { + background?: Locator + foregroundProperty?: ContrastProperty + backgroundProperty?: ContrastProperty + minimum?: number | "text" + label: string +} + +interface ContrastStyles { + foreground: string + foregroundOpacity: number + backgroundLayers: Array<{ color: string; opacity: number }> + fontSize: number + fontWeight: number +} + +export function parseCssColor(value: string): RgbaColor { + const match = value.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i) + if (match) { + const alpha = match[4]?.endsWith("%") ? Number.parseFloat(match[4]) / 100 : Number.parseFloat(match[4] ?? "1") + return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a: alpha } + } + + const srgb = value.match(/^color\(\s*srgb\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/i) + if (srgb) { + const alpha = srgb[4]?.endsWith("%") ? Number.parseFloat(srgb[4]) / 100 : Number.parseFloat(srgb[4] ?? "1") + return { r: Number(srgb[1]) * 255, g: Number(srgb[2]) * 255, b: Number(srgb[3]) * 255, a: alpha } + } + + const oklab = value.match(/^oklab\(\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/i) + if (!oklab) throw new Error(`Unsupported CSS color: ${value}`) + const [lightness, axisA, axisB] = oklab.slice(1, 4).map(Number) + const l = (lightness + 0.3963377774 * axisA + 0.2158037573 * axisB) ** 3 + const m = (lightness - 0.1055613458 * axisA - 0.0638541728 * axisB) ** 3 + const s = (lightness - 0.0894841775 * axisA - 1.291485548 * axisB) ** 3 + const linear = [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ] + const [r, g, b] = linear.map((channel) => { + const value = channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055 + return Math.min(255, Math.max(0, value * 255)) + }) + const alpha = oklab[4]?.endsWith("%") ? Number.parseFloat(oklab[4]) / 100 : Number.parseFloat(oklab[4] ?? "1") + return { r, g, b, a: alpha } +} + +export function composite(foreground: RgbaColor, background: RgbaColor): RgbaColor { + const alpha = foreground.a + background.a * (1 - foreground.a) + if (alpha === 0) return { r: 0, g: 0, b: 0, a: 0 } + return { + r: (foreground.r * foreground.a + background.r * background.a * (1 - foreground.a)) / alpha, + g: (foreground.g * foreground.a + background.g * background.a * (1 - foreground.a)) / alpha, + b: (foreground.b * foreground.a + background.b * background.a * (1 - foreground.a)) / alpha, + a: alpha, + } +} + +export function contrastRatio(first: RgbaColor, second: RgbaColor): number { + const luminance = ({ r, g, b }: RgbaColor) => { + const channels = [r, g, b].map((channel) => { + const value = channel / 255 + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] + } + const lighter = Math.max(luminance(first), luminance(second)) + const darker = Math.min(luminance(first), luminance(second)) + return (lighter + 0.05) / (darker + 0.05) +} + +export function requiredTextContrast(fontSize: number, fontWeight: number): number { + return fontSize >= 24 || (fontSize >= 56 / 3 && fontWeight >= 700) ? 3 : 4.5 +} + +export async function expectContrast(foreground: Locator, options: ContrastOptions) { + const backgroundToken = options.background ? `contrast-${Date.now()}-${Math.random()}` : null + if (options.background && backgroundToken) { + await options.background.evaluate( + (element, token) => element.setAttribute("data-contrast-background", token), + backgroundToken, + ) + } + let styles: ContrastStyles + try { + styles = await foreground.evaluate( + (element, { backgroundToken, foregroundProperty, backgroundProperty }): ContrastStyles => { + const assertSupported = (current: Element, styles: CSSStyleDeclaration, allowLeafOpacity: boolean) => { + if (styles.backgroundImage !== "none") + throw new Error(`Unsupported background image on ${current.tagName.toLowerCase()}`) + if (styles.filter !== "none") + throw new Error(`Unsupported filter on ${current.tagName.toLowerCase()}`) + if (styles.backdropFilter !== "none") + throw new Error(`Unsupported backdrop filter on ${current.tagName.toLowerCase()}`) + if (styles.mixBlendMode !== "normal" || styles.backgroundBlendMode !== "normal") { + throw new Error(`Unsupported blend mode on ${current.tagName.toLowerCase()}`) + } + if (styles.maskImage !== "none") + throw new Error(`Unsupported mask on ${current.tagName.toLowerCase()}`) + if (!allowLeafOpacity && Number(styles.opacity) !== 1) { + throw new Error(`Unsupported group opacity on ${current.tagName.toLowerCase()}`) + } + } + const styleValue = (styles: CSSStyleDeclaration, property: ContrastProperty) => { + if (property === "fill" || property === "stroke") return styles[property] + return styles.getPropertyValue(property) + } + const foregroundStyles = getComputedStyle(element) + assertSupported( + element, + foregroundStyles, + foregroundStyles.backgroundColor.endsWith(", 0)") || + foregroundStyles.backgroundColor.endsWith("/ 0)"), + ) + let foregroundAncestor = element.parentElement + while (foregroundAncestor) { + assertSupported(foregroundAncestor, getComputedStyle(foregroundAncestor), false) + foregroundAncestor = foregroundAncestor.parentElement + } + const backgroundLayers: Array<{ color: string; opacity: number }> = [] + let current: Element | null = backgroundToken + ? document.querySelector(`[data-contrast-background="${CSS.escape(backgroundToken)}"]`) + : element + let first = true + while (current) { + const styles = getComputedStyle(current) + assertSupported(current, styles, first && current.childElementCount === 0) + const color = first ? styleValue(styles, backgroundProperty) : styles.backgroundColor + const propertyOpacity = + first && backgroundProperty === "fill" + ? Number(styles.fillOpacity) + : first && backgroundProperty === "stroke" + ? Number(styles.strokeOpacity) + : 1 + backgroundLayers.push({ color, opacity: Number(styles.opacity) * propertyOpacity }) + current = current.parentElement + first = false + } + return { + foreground: styleValue(foregroundStyles, foregroundProperty), + foregroundOpacity: + Number(foregroundStyles.opacity) * + (foregroundProperty === "fill" + ? Number(foregroundStyles.fillOpacity) + : foregroundProperty === "stroke" + ? Number(foregroundStyles.strokeOpacity) + : 1), + backgroundLayers, + fontSize: Number.parseFloat(foregroundStyles.fontSize), + fontWeight: Number.parseInt(foregroundStyles.fontWeight, 10) || 400, + } + }, + { + backgroundToken, + foregroundProperty: options.foregroundProperty ?? "color", + backgroundProperty: options.backgroundProperty ?? "background-color", + }, + ) + } finally { + if (options.background && backgroundToken) { + await options.background.evaluate((element) => element.removeAttribute("data-contrast-background")) + } + } + + let effectiveBackground: RgbaColor = { r: 0, g: 0, b: 0, a: 0 } + for (const layer of styles.backgroundLayers.reverse()) { + const color = parseCssColor(layer.color) + effectiveBackground = composite({ ...color, a: color.a * layer.opacity }, effectiveBackground) + } + if (effectiveBackground.a < 0.999) { + throw new Error(`${options.label}: effective background is not opaque`) + } + + const foregroundColor = parseCssColor(styles.foreground) + const effectiveForeground = composite( + { ...foregroundColor, a: foregroundColor.a * styles.foregroundOpacity }, + effectiveBackground, + ) + const ratio = contrastRatio(effectiveForeground, effectiveBackground) + const minimum = + options.minimum === "text" || options.minimum === undefined + ? requiredTextContrast(styles.fontSize, styles.fontWeight) + : options.minimum + const diagnostic = `${options.label}: ${ratio.toFixed(2)}:1 (required ${minimum}:1; foreground ${styles.foreground}; background rgba(${effectiveBackground.r.toFixed(0)}, ${effectiveBackground.g.toFixed(0)}, ${effectiveBackground.b.toFixed(0)}, ${effectiveBackground.a.toFixed(2)}))` + expect(ratio, diagnostic).toBeGreaterThanOrEqual(minimum) + return { ratio, minimum, foreground: effectiveForeground, background: effectiveBackground } +} diff --git a/webview-ui/playwright/index.tsx b/webview-ui/playwright/index.tsx index 3710fe1502..d54a3bc98e 100644 --- a/webview-ui/playwright/index.tsx +++ b/webview-ui/playwright/index.tsx @@ -1,7 +1,10 @@ -import "./vscode-theme-dark.css" import "@vscode/codicons/dist/codicon.css" +import "./themes/vscode-theme-dark.css" +import "./themes/vscode-theme-light.css" +import "./themes/vscode-theme-high-contrast.css" +import "./themes/vscode-theme-high-contrast-light.css" +import "./vscode-theme-base.css" import "../src/index.css" -import "./vscode-theme-light.css" // Components read `window.IMAGES_BASE_URI` at mount time to resolve extension // image assets. Under Playwright CT the extension host isn't present, so seed diff --git a/webview-ui/playwright/roo-code-types.ts b/webview-ui/playwright/roo-code-types.ts new file mode 100644 index 0000000000..d07b4aaddc --- /dev/null +++ b/webview-ui/playwright/roo-code-types.ts @@ -0,0 +1,50 @@ +export { DEFAULT_MODES } from "../../packages/types/src/mode" + +export const DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES = false +export const DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED = false +export const DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES = false + +export const CODEBASE_INDEX_DEFAULTS = { + MIN_SEARCH_RESULTS: 10, + MAX_SEARCH_RESULTS: 200, + DEFAULT_SEARCH_RESULTS: 50, + SEARCH_RESULTS_STEP: 10, + MIN_SEARCH_SCORE: 0, + MAX_SEARCH_SCORE: 1, + DEFAULT_SEARCH_MIN_SCORE: 0.4, + SEARCH_SCORE_STEP: 0.05, +} as const + +export const TelemetryEventName = { + MODE_SWITCH: "Mode Switched", + MODE_SELECTOR_OPENED: "Mode Selector Opened", +} as const + +export const OpenAiServiceTier = { + Default: "default", + Flex: "flex", + Priority: "priority", +} as const + +const languages = [ + "ca", + "de", + "en", + "es", + "fr", + "hi", + "id", + "it", + "ja", + "ko", + "nl", + "pl", + "pt-BR", + "ru", + "tr", + "vi", + "zh-CN", + "zh-TW", +] + +export const isLanguage = (value: string) => languages.includes(value) diff --git a/webview-ui/playwright/themes.ts b/webview-ui/playwright/themes.ts new file mode 100644 index 0000000000..65ec5ee384 --- /dev/null +++ b/webview-ui/playwright/themes.ts @@ -0,0 +1,28 @@ +import type { Page } from "@playwright/test" + +export interface VisualTheme { + name: "dark" | "light" | "high-contrast" | "high-contrast-light" + bodyClass: string + themeId: string +} + +export const visualThemes: VisualTheme[] = [ + { name: "dark", bodyClass: "vscode-dark", themeId: "Default Dark Modern" }, + { name: "light", bodyClass: "vscode-light", themeId: "Default Light Modern" }, + { name: "high-contrast", bodyClass: "vscode-high-contrast", themeId: "Default High Contrast" }, + { + name: "high-contrast-light", + bodyClass: "vscode-high-contrast-light", + themeId: "Default High Contrast Light", + }, +] + +export async function applyVisualTheme(page: Page, theme: VisualTheme) { + await page.evaluate(({ bodyClass, themeId }) => { + document.documentElement.className = bodyClass + document.documentElement.removeAttribute("style") + document.body.className = bodyClass + document.body.removeAttribute("style") + document.body.dataset.vscodeThemeId = themeId + }, theme) +} diff --git a/webview-ui/playwright/themes/vscode-theme-dark.css b/webview-ui/playwright/themes/vscode-theme-dark.css new file mode 100644 index 0000000000..285cb2179c --- /dev/null +++ b/webview-ui/playwright/themes/vscode-theme-dark.css @@ -0,0 +1,778 @@ +/* Generated from Default Dark Modern by VS Code 1.100.0. Do not edit manually. */ +.vscode-dark { + color-scheme: dark; + --vscode-actionBar-toggledBackground: #383a49; + --vscode-activityBar-activeBorder: #0078d4; + --vscode-activityBar-background: #181818; + --vscode-activityBar-border: #2b2b2b; + --vscode-activityBar-dropBorder: #d7d7d7; + --vscode-activityBar-foreground: #d7d7d7; + --vscode-activityBar-inactiveForeground: #868686; + --vscode-activityBarBadge-background: #0078d4; + --vscode-activityBarBadge-foreground: #ffffff; + --vscode-activityBarTop-activeBorder: #e7e7e7; + --vscode-activityBarTop-dropBorder: #e7e7e7; + --vscode-activityBarTop-foreground: #e7e7e7; + --vscode-activityBarTop-inactiveForeground: rgba(231, 231, 231, 0.6); + --vscode-activityErrorBadge-background: #f14c4c; + --vscode-activityErrorBadge-foreground: #000000; + --vscode-activityWarningBadge-background: #cca700; + --vscode-activityWarningBadge-foreground: #000000; + --vscode-badge-background: #616161; + --vscode-badge-foreground: #f8f8f8; + --vscode-banner-background: #04395e; + --vscode-banner-foreground: #ffffff; + --vscode-banner-iconForeground: #3794ff; + --vscode-breadcrumb-activeSelectionForeground: #e0e0e0; + --vscode-breadcrumb-background: #1f1f1f; + --vscode-breadcrumb-focusForeground: #e0e0e0; + --vscode-breadcrumb-foreground: rgba(204, 204, 204, 0.8); + --vscode-breadcrumbPicker-background: #202020; + --vscode-button-background: #0078d4; + --vscode-button-border: rgba(255, 255, 255, 0.07); + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #026ec1; + --vscode-button-secondaryBackground: #313131; + --vscode-button-secondaryForeground: #cccccc; + --vscode-button-secondaryHoverBackground: #3c3c3c; + --vscode-button-separator: rgba(255, 255, 255, 0.4); + --vscode-chart-axis: rgba(191, 191, 191, 0.4); + --vscode-chart-guide: rgba(191, 191, 191, 0.2); + --vscode-chart-line: #236b8e; + --vscode-charts-blue: #3794ff; + --vscode-charts-foreground: #cccccc; + --vscode-charts-green: #89d185; + --vscode-charts-lines: rgba(204, 204, 204, 0.5); + --vscode-charts-orange: #d18616; + --vscode-charts-purple: #b180d7; + --vscode-charts-red: #f14c4c; + --vscode-charts-yellow: #cca700; + --vscode-chat-avatarBackground: #1f1f1f; + --vscode-chat-avatarForeground: #cccccc; + --vscode-chat-editedFileForeground: #e2c08d; + --vscode-chat-requestBackground: rgba(31, 31, 31, 0.62); + --vscode-chat-requestBorder: rgba(255, 255, 255, 0.1); + --vscode-chat-slashCommandBackground: #34414b; + --vscode-chat-slashCommandForeground: #40a6ff; + --vscode-checkbox-background: #313131; + --vscode-checkbox-border: #3c3c3c; + --vscode-checkbox-disabled.background: #646464; + --vscode-checkbox-disabled.foreground: #989898; + --vscode-checkbox-foreground: #cccccc; + --vscode-checkbox-selectBackground: #202020; + --vscode-checkbox-selectBorder: #cccccc; + --vscode-commandCenter-activeBackground: rgba(255, 255, 255, 0.08); + --vscode-commandCenter-activeBorder: rgba(204, 204, 204, 0.3); + --vscode-commandCenter-activeForeground: #cccccc; + --vscode-commandCenter-background: rgba(255, 255, 255, 0.05); + --vscode-commandCenter-border: rgba(204, 204, 204, 0.2); + --vscode-commandCenter-debuggingBackground: rgba(0, 120, 212, 0.26); + --vscode-commandCenter-foreground: #cccccc; + --vscode-commandCenter-inactiveBorder: rgba(157, 157, 157, 0.25); + --vscode-commandCenter-inactiveForeground: #9d9d9d; + --vscode-commentsView-resolvedIcon: rgba(204, 204, 204, 0.5); + --vscode-commentsView-unresolvedIcon: #0078d4; + --vscode-debugConsole-errorForeground: #f85149; + --vscode-debugConsole-infoForeground: #3794ff; + --vscode-debugConsole-sourceForeground: #cccccc; + --vscode-debugConsole-warningForeground: #cca700; + --vscode-debugConsoleInputIcon-foreground: #cccccc; + --vscode-debugExceptionWidget-background: #420b0d; + --vscode-debugExceptionWidget-border: #a31515; + --vscode-debugIcon-breakpointCurrentStackframeForeground: #ffcc00; + --vscode-debugIcon-breakpointDisabledForeground: #848484; + --vscode-debugIcon-breakpointForeground: #e51400; + --vscode-debugIcon-breakpointStackframeForeground: #89d185; + --vscode-debugIcon-breakpointUnverifiedForeground: #848484; + --vscode-debugIcon-continueForeground: #75beff; + --vscode-debugIcon-disconnectForeground: #f48771; + --vscode-debugIcon-pauseForeground: #75beff; + --vscode-debugIcon-restartForeground: #89d185; + --vscode-debugIcon-startForeground: #89d185; + --vscode-debugIcon-stepBackForeground: #75beff; + --vscode-debugIcon-stepIntoForeground: #75beff; + --vscode-debugIcon-stepOutForeground: #75beff; + --vscode-debugIcon-stepOverForeground: #75beff; + --vscode-debugIcon-stopForeground: #f48771; + --vscode-debugTokenExpression-boolean: #4e94ce; + --vscode-debugTokenExpression-error: #f48771; + --vscode-debugTokenExpression-name: #c586c0; + --vscode-debugTokenExpression-number: #b5cea8; + --vscode-debugTokenExpression-string: #ce9178; + --vscode-debugTokenExpression-type: #4a90e2; + --vscode-debugTokenExpression-value: rgba(204, 204, 204, 0.6); + --vscode-debugToolBar-background: #181818; + --vscode-debugView-exceptionLabelBackground: #6c2022; + --vscode-debugView-exceptionLabelForeground: #cccccc; + --vscode-debugView-stateLabelBackground: rgba(136, 136, 136, 0.27); + --vscode-debugView-stateLabelForeground: #cccccc; + --vscode-debugView-valueChangedHighlight: #569cd6; + --vscode-descriptionForeground: #9d9d9d; + --vscode-diffEditor-diagonalFill: rgba(204, 204, 204, 0.2); + --vscode-diffEditor-insertedLineBackground: rgba(155, 185, 85, 0.2); + --vscode-diffEditor-insertedTextBackground: rgba(156, 204, 44, 0.2); + --vscode-diffEditor-move.border: rgba(139, 139, 139, 0.61); + --vscode-diffEditor-moveActive.border: #ffa500; + --vscode-diffEditor-removedLineBackground: rgba(255, 0, 0, 0.2); + --vscode-diffEditor-removedTextBackground: rgba(255, 0, 0, 0.2); + --vscode-diffEditor-unchangedCodeBackground: rgba(116, 116, 116, 0.16); + --vscode-diffEditor-unchangedRegionBackground: #181818; + --vscode-diffEditor-unchangedRegionForeground: #cccccc; + --vscode-diffEditor-unchangedRegionShadow: #000000; + --vscode-disabledForeground: rgba(204, 204, 204, 0.5); + --vscode-dropdown-background: #313131; + --vscode-dropdown-border: #3c3c3c; + --vscode-dropdown-foreground: #cccccc; + --vscode-dropdown-listBackground: #1f1f1f; + --vscode-editor-background: #1f1f1f; + --vscode-editor-compositionBorder: #ffffff; + --vscode-editor-findMatchBackground: #9e6a03; + --vscode-editor-findMatchHighlightBackground: rgba(234, 92, 0, 0.33); + --vscode-editor-findRangeHighlightBackground: rgba(58, 61, 65, 0.4); + --vscode-editor-focusedStackFrameHighlightBackground: rgba(122, 189, 122, 0.3); + --vscode-editor-foldBackground: rgba(38, 79, 120, 0.3); + --vscode-editor-foldPlaceholderForeground: #808080; + --vscode-editor-font-size: 14px; + --vscode-editor-font-weight: normal; + --vscode-editor-foreground: #cccccc; + --vscode-editor-hoverHighlightBackground: rgba(38, 79, 120, 0.25); + --vscode-editor-inactiveSelectionBackground: #3a3d41; + --vscode-editor-inlineValuesBackground: rgba(255, 200, 0, 0.2); + --vscode-editor-inlineValuesForeground: rgba(255, 255, 255, 0.5); + --vscode-editor-lineHighlightBorder: #282828; + --vscode-editor-linkedEditingBackground: rgba(255, 0, 0, 0.3); + --vscode-editor-placeholder.foreground: rgba(255, 255, 255, 0.34); + --vscode-editor-rangeHighlightBackground: rgba(255, 255, 255, 0.04); + --vscode-editor-selectionBackground: #264f78; + --vscode-editor-selectionHighlightBackground: rgba(173, 214, 255, 0.15); + --vscode-editor-snippetFinalTabstopHighlightBorder: #525252; + --vscode-editor-snippetTabstopHighlightBackground: rgba(124, 124, 124, 0.3); + --vscode-editor-stackFrameHighlightBackground: rgba(255, 255, 0, 0.2); + --vscode-editor-symbolHighlightBackground: rgba(234, 92, 0, 0.33); + --vscode-editor-wordHighlightBackground: rgba(87, 87, 87, 0.72); + --vscode-editor-wordHighlightStrongBackground: rgba(0, 73, 114, 0.72); + --vscode-editor-wordHighlightTextBackground: rgba(87, 87, 87, 0.72); + --vscode-editorActionList-background: #202020; + --vscode-editorActionList-focusBackground: #04395e; + --vscode-editorActionList-focusForeground: #ffffff; + --vscode-editorActionList-foreground: #cccccc; + --vscode-editorActiveLineNumber-foreground: #c6c6c6; + --vscode-editorBracketHighlight-foreground1: #ffd700; + --vscode-editorBracketHighlight-foreground2: #da70d6; + --vscode-editorBracketHighlight-foreground3: #179fff; + --vscode-editorBracketHighlight-foreground4: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-foreground5: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-foreground6: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-unexpectedBracket.foreground: rgba(255, 18, 18, 0.8); + --vscode-editorBracketMatch-background: rgba(0, 100, 0, 0.1); + --vscode-editorBracketMatch-border: #888888; + --vscode-editorBracketPairGuide-activeBackground1: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground2: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground3: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground4: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground5: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground6: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background1: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background2: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background3: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background4: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background5: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background6: rgba(0, 0, 0, 0); + --vscode-editorCodeLens-foreground: #999999; + --vscode-editorCommentsWidget-rangeActiveBackground: rgba(0, 120, 212, 0.1); + --vscode-editorCommentsWidget-rangeBackground: rgba(0, 120, 212, 0.1); + --vscode-editorCommentsWidget-replyInputBackground: #252526; + --vscode-editorCommentsWidget-resolvedBorder: rgba(204, 204, 204, 0.5); + --vscode-editorCommentsWidget-unresolvedBorder: #0078d4; + --vscode-editorCursor-foreground: #aeafad; + --vscode-editorError-foreground: #f14c4c; + --vscode-editorGhostText-foreground: rgba(255, 255, 255, 0.34); + --vscode-editorGroup-border: rgba(255, 255, 255, 0.09); + --vscode-editorGroup-dropBackground: rgba(83, 89, 93, 0.5); + --vscode-editorGroup-dropIntoPromptBackground: #202020; + --vscode-editorGroup-dropIntoPromptForeground: #cccccc; + --vscode-editorGroupHeader-noTabsBackground: #1f1f1f; + --vscode-editorGroupHeader-tabsBackground: #181818; + --vscode-editorGroupHeader-tabsBorder: #2b2b2b; + --vscode-editorGutter-addedBackground: #2ea043; + --vscode-editorGutter-addedSecondaryBackground: #175021; + --vscode-editorGutter-background: #1f1f1f; + --vscode-editorGutter-commentGlyphForeground: #cccccc; + --vscode-editorGutter-commentRangeForeground: #37373d; + --vscode-editorGutter-commentUnresolvedGlyphForeground: #cccccc; + --vscode-editorGutter-deletedBackground: #f85149; + --vscode-editorGutter-deletedSecondaryBackground: #b91007; + --vscode-editorGutter-foldingControlForeground: #cccccc; + --vscode-editorGutter-itemBackground: #37373d; + --vscode-editorGutter-itemGlyphForeground: #cccccc; + --vscode-editorGutter-modifiedBackground: #0078d4; + --vscode-editorGutter-modifiedSecondaryBackground: #003c6a; + --vscode-editorHint-foreground: rgba(238, 238, 238, 0.7); + --vscode-editorHoverWidget-background: #202020; + --vscode-editorHoverWidget-border: #454545; + --vscode-editorHoverWidget-foreground: #cccccc; + --vscode-editorHoverWidget-highlightForeground: #2aaaff; + --vscode-editorHoverWidget-statusBarBackground: #262626; + --vscode-editorIndentGuide-activeBackground: rgba(227, 228, 226, 0.16); + --vscode-editorIndentGuide-activeBackground1: #707070; + --vscode-editorIndentGuide-activeBackground2: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground3: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground4: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground5: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground6: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background: rgba(227, 228, 226, 0.16); + --vscode-editorIndentGuide-background1: #404040; + --vscode-editorIndentGuide-background2: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background3: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background4: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background5: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background6: rgba(0, 0, 0, 0); + --vscode-editorInfo-foreground: #3794ff; + --vscode-editorInlayHint-background: rgba(97, 97, 97, 0.1); + --vscode-editorInlayHint-foreground: #969696; + --vscode-editorInlayHint-parameterBackground: rgba(97, 97, 97, 0.1); + --vscode-editorInlayHint-parameterForeground: #969696; + --vscode-editorInlayHint-typeBackground: rgba(97, 97, 97, 0.1); + --vscode-editorInlayHint-typeForeground: #969696; + --vscode-editorLightBulb-foreground: #ffcc00; + --vscode-editorLightBulbAi-foreground: #ffcc00; + --vscode-editorLightBulbAutoFix-foreground: #75beff; + --vscode-editorLineNumber-activeForeground: #cccccc; + --vscode-editorLineNumber-foreground: #6e7681; + --vscode-editorLink-activeForeground: #4e94ce; + --vscode-editorMarkerNavigation-background: #1f1f1f; + --vscode-editorMarkerNavigationError-background: #f14c4c; + --vscode-editorMarkerNavigationError-headerBackground: rgba(241, 76, 76, 0.1); + --vscode-editorMarkerNavigationInfo-background: #3794ff; + --vscode-editorMarkerNavigationInfo-headerBackground: rgba(55, 148, 255, 0.1); + --vscode-editorMarkerNavigationWarning-background: #cca700; + --vscode-editorMarkerNavigationWarning-headerBackground: rgba(204, 167, 0, 0.1); + --vscode-editorMinimap-inlineChatInserted: rgba(156, 204, 44, 0.12); + --vscode-editorMultiCursor-primary.foreground: #aeafad; + --vscode-editorMultiCursor-secondary.foreground: #aeafad; + --vscode-editorOverviewRuler-addedForeground: rgba(46, 160, 67, 0.6); + --vscode-editorOverviewRuler-border: #010409; + --vscode-editorOverviewRuler-bracketMatchForeground: #a0a0a0; + --vscode-editorOverviewRuler-commentForeground: #37373d; + --vscode-editorOverviewRuler-commentUnresolvedForeground: #37373d; + --vscode-editorOverviewRuler-commonContentForeground: rgba(96, 96, 96, 0.4); + --vscode-editorOverviewRuler-currentContentForeground: rgba(64, 200, 174, 0.5); + --vscode-editorOverviewRuler-deletedForeground: rgba(248, 81, 73, 0.6); + --vscode-editorOverviewRuler-errorForeground: rgba(255, 18, 18, 0.7); + --vscode-editorOverviewRuler-findMatchForeground: rgba(209, 134, 22, 0.49); + --vscode-editorOverviewRuler-incomingContentForeground: rgba(64, 166, 255, 0.5); + --vscode-editorOverviewRuler-infoForeground: #3794ff; + --vscode-editorOverviewRuler-inlineChatInserted: rgba(156, 204, 44, 0.12); + --vscode-editorOverviewRuler-inlineChatRemoved: rgba(255, 0, 0, 0.12); + --vscode-editorOverviewRuler-modifiedForeground: rgba(0, 120, 212, 0.6); + --vscode-editorOverviewRuler-rangeHighlightForeground: rgba(0, 122, 204, 0.6); + --vscode-editorOverviewRuler-selectionHighlightForeground: rgba(160, 160, 160, 0.8); + --vscode-editorOverviewRuler-warningForeground: #cca700; + --vscode-editorOverviewRuler-wordHighlightForeground: rgba(160, 160, 160, 0.8); + --vscode-editorOverviewRuler-wordHighlightStrongForeground: rgba(192, 160, 192, 0.8); + --vscode-editorOverviewRuler-wordHighlightTextForeground: rgba(160, 160, 160, 0.8); + --vscode-editorPane-background: #1f1f1f; + --vscode-editorRuler-foreground: #5a5a5a; + --vscode-editorStickyScroll-background: #1f1f1f; + --vscode-editorStickyScroll-shadow: #000000; + --vscode-editorStickyScrollHover-background: #2a2d2e; + --vscode-editorSuggestWidget-background: #202020; + --vscode-editorSuggestWidget-border: #454545; + --vscode-editorSuggestWidget-focusHighlightForeground: #2aaaff; + --vscode-editorSuggestWidget-foreground: #cccccc; + --vscode-editorSuggestWidget-highlightForeground: #2aaaff; + --vscode-editorSuggestWidget-selectedBackground: #04395e; + --vscode-editorSuggestWidget-selectedForeground: #ffffff; + --vscode-editorSuggestWidget-selectedIconForeground: #ffffff; + --vscode-editorSuggestWidgetStatus-foreground: rgba(204, 204, 204, 0.5); + --vscode-editorUnicodeHighlight-border: #cca700; + --vscode-editorUnnecessaryCode-opacity: rgba(0, 0, 0, 0.67); + --vscode-editorWarning-foreground: #cca700; + --vscode-editorWatermark-foreground: rgba(204, 204, 204, 0.6); + --vscode-editorWhitespace-foreground: rgba(227, 228, 226, 0.16); + --vscode-editorWidget-background: #202020; + --vscode-editorWidget-border: #454545; + --vscode-editorWidget-foreground: #cccccc; + --vscode-errorForeground: #f85149; + --vscode-extensionBadge-remoteBackground: #0078d4; + --vscode-extensionBadge-remoteForeground: #ffffff; + --vscode-extensionButton-background: #0078d4; + --vscode-extensionButton-foreground: #ffffff; + --vscode-extensionButton-hoverBackground: #026ec1; + --vscode-extensionButton-prominentBackground: #0078d4; + --vscode-extensionButton-prominentForeground: #ffffff; + --vscode-extensionButton-prominentHoverBackground: #026ec1; + --vscode-extensionButton-separator: rgba(255, 255, 255, 0.4); + --vscode-extensionIcon-preReleaseForeground: #1d9271; + --vscode-extensionIcon-privateForeground: rgba(255, 255, 255, 0.38); + --vscode-extensionIcon-sponsorForeground: #d758b3; + --vscode-extensionIcon-starForeground: #ff8e00; + --vscode-extensionIcon-verifiedForeground: #4daafc; + --vscode-focusBorder: #0078d4; + --vscode-font-size: 13px; + --vscode-font-weight: normal; + --vscode-foreground: #cccccc; + --vscode-gauge-background: #007acc; + --vscode-gauge-errorBackground: #be1100; + --vscode-gauge-errorForeground: rgba(190, 17, 0, 0.3); + --vscode-gauge-foreground: rgba(0, 122, 204, 0.3); + --vscode-gauge-warningBackground: #b89500; + --vscode-gauge-warningForeground: rgba(184, 149, 0, 0.3); + --vscode-git-blame.editorDecorationForeground: #969696; + --vscode-gitDecoration-addedResourceForeground: #81b88b; + --vscode-gitDecoration-conflictingResourceForeground: #e4676b; + --vscode-gitDecoration-deletedResourceForeground: #c74e39; + --vscode-gitDecoration-ignoredResourceForeground: #8c8c8c; + --vscode-gitDecoration-modifiedResourceForeground: #e2c08d; + --vscode-gitDecoration-renamedResourceForeground: #73c991; + --vscode-gitDecoration-stageDeletedResourceForeground: #c74e39; + --vscode-gitDecoration-stageModifiedResourceForeground: #e2c08d; + --vscode-gitDecoration-submoduleResourceForeground: #8db9e2; + --vscode-gitDecoration-untrackedResourceForeground: #73c991; + --vscode-icon-foreground: #cccccc; + --vscode-inlineChat-background: #202020; + --vscode-inlineChat-border: #454545; + --vscode-inlineChat-foreground: #cccccc; + --vscode-inlineChat-shadow: rgba(0, 0, 0, 0.36); + --vscode-inlineChatDiff-inserted: rgba(156, 204, 44, 0.1); + --vscode-inlineChatDiff-removed: rgba(255, 0, 0, 0.1); + --vscode-inlineChatInput-background: #313131; + --vscode-inlineChatInput-border: #454545; + --vscode-inlineChatInput-focusBorder: #0078d4; + --vscode-inlineChatInput-placeholderForeground: #989898; + --vscode-inlineEdit-gutterIndicator.background: rgba(24, 24, 24, 0.5); + --vscode-inlineEdit-gutterIndicator.primaryBackground: rgba(0, 120, 212, 0.4); + --vscode-inlineEdit-gutterIndicator.primaryBorder: #0078d4; + --vscode-inlineEdit-gutterIndicator.primaryForeground: #ffffff; + --vscode-inlineEdit-gutterIndicator.secondaryBackground: #313131; + --vscode-inlineEdit-gutterIndicator.secondaryBorder: #313131; + --vscode-inlineEdit-gutterIndicator.secondaryForeground: #cccccc; + --vscode-inlineEdit-gutterIndicator.successfulBackground: #0078d4; + --vscode-inlineEdit-gutterIndicator.successfulBorder: #0078d4; + --vscode-inlineEdit-gutterIndicator.successfulForeground: #ffffff; + --vscode-inlineEdit-modifiedBackground: rgba(156, 204, 44, 0.06); + --vscode-inlineEdit-modifiedBorder: rgba(156, 204, 44, 0.2); + --vscode-inlineEdit-modifiedChangedLineBackground: rgba(155, 185, 85, 0.14); + --vscode-inlineEdit-modifiedChangedTextBackground: rgba(156, 204, 44, 0.14); + --vscode-inlineEdit-originalBackground: rgba(255, 0, 0, 0.04); + --vscode-inlineEdit-originalBorder: rgba(255, 0, 0, 0.2); + --vscode-inlineEdit-originalChangedLineBackground: rgba(255, 0, 0, 0.16); + --vscode-inlineEdit-originalChangedTextBackground: rgba(255, 0, 0, 0.16); + --vscode-inlineEdit-tabWillAcceptModifiedBorder: rgba(156, 204, 44, 0.2); + --vscode-inlineEdit-tabWillAcceptOriginalBorder: rgba(255, 0, 0, 0.2); + --vscode-input-background: #313131; + --vscode-input-border: #3c3c3c; + --vscode-input-foreground: #cccccc; + --vscode-input-placeholderForeground: #989898; + --vscode-inputOption-activeBackground: rgba(36, 137, 219, 0.51); + --vscode-inputOption-activeBorder: #2488db; + --vscode-inputOption-activeForeground: #ffffff; + --vscode-inputOption-hoverBackground: rgba(90, 93, 94, 0.5); + --vscode-inputValidation-errorBackground: #5a1d1d; + --vscode-inputValidation-errorBorder: #be1100; + --vscode-inputValidation-infoBackground: #063b49; + --vscode-inputValidation-infoBorder: #007acc; + --vscode-inputValidation-warningBackground: #352a05; + --vscode-inputValidation-warningBorder: #b89500; + --vscode-interactive-activeCodeBorder: #007acc; + --vscode-interactive-inactiveCodeBorder: #37373d; + --vscode-keybindingLabel-background: rgba(128, 128, 128, 0.17); + --vscode-keybindingLabel-border: rgba(51, 51, 51, 0.6); + --vscode-keybindingLabel-bottomBorder: rgba(68, 68, 68, 0.6); + --vscode-keybindingLabel-foreground: #cccccc; + --vscode-keybindingTable-headerBackground: rgba(204, 204, 204, 0.04); + --vscode-keybindingTable-rowsBackground: rgba(204, 204, 204, 0.04); + --vscode-list-activeSelectionBackground: #04395e; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-list-activeSelectionIconForeground: #ffffff; + --vscode-list-deemphasizedForeground: #8c8c8c; + --vscode-list-dropBackground: #383b3d; + --vscode-list-dropBetweenBackground: #cccccc; + --vscode-list-errorForeground: #f88070; + --vscode-list-filterMatchBackground: rgba(234, 92, 0, 0.33); + --vscode-list-focusHighlightForeground: #2aaaff; + --vscode-list-focusOutline: #0078d4; + --vscode-list-highlightForeground: #2aaaff; + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-inactiveSelectionBackground: #37373d; + --vscode-list-invalidItemForeground: #b89500; + --vscode-list-warningForeground: #cca700; + --vscode-listFilterWidget-background: #202020; + --vscode-listFilterWidget-noMatchesOutline: #be1100; + --vscode-listFilterWidget-outline: rgba(0, 0, 0, 0); + --vscode-listFilterWidget-shadow: rgba(0, 0, 0, 0.36); + --vscode-menu-background: #1f1f1f; + --vscode-menu-border: #454545; + --vscode-menu-foreground: #cccccc; + --vscode-menu-selectionBackground: #0078d4; + --vscode-menu-selectionForeground: #ffffff; + --vscode-menu-separatorBackground: #454545; + --vscode-menubar-selectionBackground: rgba(90, 93, 94, 0.31); + --vscode-menubar-selectionForeground: #cccccc; + --vscode-merge-commonContentBackground: rgba(96, 96, 96, 0.16); + --vscode-merge-commonHeaderBackground: rgba(96, 96, 96, 0.4); + --vscode-merge-currentContentBackground: rgba(64, 200, 174, 0.2); + --vscode-merge-currentHeaderBackground: rgba(64, 200, 174, 0.5); + --vscode-merge-incomingContentBackground: rgba(64, 166, 255, 0.2); + --vscode-merge-incomingHeaderBackground: rgba(64, 166, 255, 0.5); + --vscode-mergeEditor-change.background: rgba(155, 185, 85, 0.2); + --vscode-mergeEditor-change.word.background: rgba(156, 204, 44, 0.2); + --vscode-mergeEditor-changeBase.background: #4b1818; + --vscode-mergeEditor-changeBase.word.background: #6f1313; + --vscode-mergeEditor-conflict.handled.minimapOverViewRuler: rgba(173, 172, 168, 0.93); + --vscode-mergeEditor-conflict.handledFocused.border: rgba(193, 193, 193, 0.8); + --vscode-mergeEditor-conflict.handledUnfocused.border: rgba(134, 134, 134, 0.29); + --vscode-mergeEditor-conflict.input1.background: rgba(64, 200, 174, 0.2); + --vscode-mergeEditor-conflict.input2.background: rgba(64, 166, 255, 0.2); + --vscode-mergeEditor-conflict.unhandled.minimapOverViewRuler: #fcba03; + --vscode-mergeEditor-conflict.unhandledFocused.border: #ffa600; + --vscode-mergeEditor-conflict.unhandledUnfocused.border: rgba(255, 166, 0, 0.48); + --vscode-mergeEditor-conflictingLines.background: rgba(255, 234, 0, 0.28); + --vscode-minimap-chatEditHighlight: rgba(31, 31, 31, 0.6); + --vscode-minimap-errorHighlight: rgba(255, 18, 18, 0.7); + --vscode-minimap-findMatchHighlight: #d18616; + --vscode-minimap-foregroundOpacity: #000000; + --vscode-minimap-infoHighlight: #3794ff; + --vscode-minimap-selectionHighlight: #264f78; + --vscode-minimap-selectionOccurrenceHighlight: #676767; + --vscode-minimap-warningHighlight: #cca700; + --vscode-minimapGutter-addedBackground: #2ea043; + --vscode-minimapGutter-deletedBackground: #f85149; + --vscode-minimapGutter-modifiedBackground: #0078d4; + --vscode-minimapSlider-activeBackground: rgba(191, 191, 191, 0.2); + --vscode-minimapSlider-background: rgba(121, 121, 121, 0.2); + --vscode-minimapSlider-hoverBackground: rgba(100, 100, 100, 0.35); + --vscode-multiDiffEditor-background: #1f1f1f; + --vscode-multiDiffEditor-border: #2b2b2b; + --vscode-multiDiffEditor-headerBackground: #262626; + --vscode-notebook-cellBorderColor: #37373d; + --vscode-notebook-cellEditorBackground: #181818; + --vscode-notebook-cellInsertionIndicator: #0078d4; + --vscode-notebook-cellStatusBarItemHoverBackground: rgba(255, 255, 255, 0.15); + --vscode-notebook-cellToolbarSeparator: rgba(128, 128, 128, 0.35); + --vscode-notebook-editorBackground: #1f1f1f; + --vscode-notebook-focusedCellBorder: #0078d4; + --vscode-notebook-focusedEditorBorder: #0078d4; + --vscode-notebook-inactiveFocusedCellBorder: #37373d; + --vscode-notebook-selectedCellBackground: #37373d; + --vscode-notebook-selectedCellBorder: #37373d; + --vscode-notebook-symbolHighlightBackground: rgba(255, 255, 255, 0.04); + --vscode-notebookEditorOverviewRuler-runningCellForeground: #89d185; + --vscode-notebookScrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); + --vscode-notebookScrollbarSlider-background: rgba(121, 121, 121, 0.4); + --vscode-notebookScrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-notebookStatusErrorIcon-foreground: #f85149; + --vscode-notebookStatusRunningIcon-foreground: #cccccc; + --vscode-notebookStatusSuccessIcon-foreground: #89d185; + --vscode-notificationCenter-border: #313131; + --vscode-notificationCenterHeader-background: #1f1f1f; + --vscode-notificationCenterHeader-foreground: #cccccc; + --vscode-notificationLink-foreground: #4daafc; + --vscode-notificationToast-border: #313131; + --vscode-notifications-background: #1f1f1f; + --vscode-notifications-border: #2b2b2b; + --vscode-notifications-foreground: #cccccc; + --vscode-notificationsErrorIcon-foreground: #f14c4c; + --vscode-notificationsInfoIcon-foreground: #3794ff; + --vscode-notificationsWarningIcon-foreground: #cca700; + --vscode-panel-background: #181818; + --vscode-panel-border: #2b2b2b; + --vscode-panel-dropBorder: #cccccc; + --vscode-panelInput-border: #2b2b2b; + --vscode-panelSection-border: #2b2b2b; + --vscode-panelSection-dropBackground: rgba(83, 89, 93, 0.5); + --vscode-panelSectionHeader-background: rgba(128, 128, 128, 0.2); + --vscode-panelStickyScroll-background: #181818; + --vscode-panelStickyScroll-shadow: #000000; + --vscode-panelTitle-activeBorder: #0078d4; + --vscode-panelTitle-activeForeground: #cccccc; + --vscode-panelTitle-inactiveForeground: #9d9d9d; + --vscode-panelTitleBadge-background: #0078d4; + --vscode-panelTitleBadge-foreground: #ffffff; + --vscode-peekView-border: #3794ff; + --vscode-peekViewEditor-background: #1f1f1f; + --vscode-peekViewEditor-matchHighlightBackground: rgba(187, 128, 9, 0.4); + --vscode-peekViewEditorGutter-background: #1f1f1f; + --vscode-peekViewEditorStickyScroll-background: #1f1f1f; + --vscode-peekViewResult-background: #1f1f1f; + --vscode-peekViewResult-fileForeground: #ffffff; + --vscode-peekViewResult-lineForeground: #bbbbbb; + --vscode-peekViewResult-matchHighlightBackground: rgba(187, 128, 9, 0.4); + --vscode-peekViewResult-selectionBackground: rgba(51, 153, 255, 0.2); + --vscode-peekViewResult-selectionForeground: #ffffff; + --vscode-peekViewTitle-background: #252526; + --vscode-peekViewTitleDescription-foreground: rgba(204, 204, 204, 0.7); + --vscode-peekViewTitleLabel-foreground: #ffffff; + --vscode-pickerGroup-border: #3c3c3c; + --vscode-pickerGroup-foreground: #3794ff; + --vscode-ports-iconRunningProcessForeground: #369432; + --vscode-problemsErrorIcon-foreground: #f14c4c; + --vscode-problemsInfoIcon-foreground: #3794ff; + --vscode-problemsWarningIcon-foreground: #cca700; + --vscode-profileBadge-background: #4d4d4d; + --vscode-profileBadge-foreground: #ffffff; + --vscode-profiles-sashBorder: #2b2b2b; + --vscode-progressBar-background: #0078d4; + --vscode-prompt-frontMatter.background: #191919; + --vscode-prompt-frontMatter.inactiveBackground: #1c1c1c; + --vscode-quickInput-background: #222222; + --vscode-quickInput-foreground: #cccccc; + --vscode-quickInputList-focusBackground: #04395e; + --vscode-quickInputList-focusForeground: #ffffff; + --vscode-quickInputList-focusIconForeground: #ffffff; + --vscode-quickInputTitle-background: rgba(255, 255, 255, 0.1); + --vscode-radio-activeBackground: rgba(36, 137, 219, 0.51); + --vscode-radio-activeBorder: #2488db; + --vscode-radio-activeForeground: #ffffff; + --vscode-radio-inactiveBorder: rgba(255, 255, 255, 0.2); + --vscode-radio-inactiveHoverBackground: rgba(90, 93, 94, 0.5); + --vscode-sash-hoverBorder: #0078d4; + --vscode-scmGraph-foreground1: #ffb000; + --vscode-scmGraph-foreground2: #dc267f; + --vscode-scmGraph-foreground3: #994f00; + --vscode-scmGraph-foreground4: #40b0a6; + --vscode-scmGraph-foreground5: #b66dff; + --vscode-scmGraph-historyItemBaseRefColor: #ea5c00; + --vscode-scmGraph-historyItemHoverAdditionsForeground: #81b88b; + --vscode-scmGraph-historyItemHoverDefaultLabelBackground: #616161; + --vscode-scmGraph-historyItemHoverDefaultLabelForeground: #cccccc; + --vscode-scmGraph-historyItemHoverDeletionsForeground: #c74e39; + --vscode-scmGraph-historyItemHoverLabelForeground: #ffffff; + --vscode-scmGraph-historyItemRefColor: #3794ff; + --vscode-scmGraph-historyItemRemoteRefColor: #b180d7; + --vscode-scrollbar-shadow: #000000; + --vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); + --vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4); + --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-search-resultsInfoForeground: rgba(204, 204, 204, 0.65); + --vscode-searchEditor-findMatchBackground: rgba(234, 92, 0, 0.22); + --vscode-searchEditor-textInputBorder: #3c3c3c; + --vscode-settings-checkboxBackground: #313131; + --vscode-settings-checkboxBorder: #3c3c3c; + --vscode-settings-checkboxForeground: #cccccc; + --vscode-settings-dropdownBackground: #313131; + --vscode-settings-dropdownBorder: #3c3c3c; + --vscode-settings-dropdownForeground: #cccccc; + --vscode-settings-dropdownListBorder: #454545; + --vscode-settings-focusedRowBackground: rgba(42, 45, 46, 0.6); + --vscode-settings-focusedRowBorder: #0078d4; + --vscode-settings-headerBorder: #2b2b2b; + --vscode-settings-headerForeground: #ffffff; + --vscode-settings-modifiedItemIndicator: rgba(187, 128, 9, 0.4); + --vscode-settings-numberInputBackground: #313131; + --vscode-settings-numberInputBorder: #3c3c3c; + --vscode-settings-numberInputForeground: #cccccc; + --vscode-settings-rowHoverBackground: rgba(42, 45, 46, 0.3); + --vscode-settings-sashBorder: #2b2b2b; + --vscode-settings-settingsHeaderHoverForeground: rgba(255, 255, 255, 0.7); + --vscode-settings-textInputBackground: #313131; + --vscode-settings-textInputBorder: #3c3c3c; + --vscode-settings-textInputForeground: #cccccc; + --vscode-sideBar-background: #181818; + --vscode-sideBar-border: #2b2b2b; + --vscode-sideBar-dropBackground: rgba(83, 89, 93, 0.5); + --vscode-sideBar-foreground: #cccccc; + --vscode-sideBarActivityBarTop-border: #2b2b2b; + --vscode-sideBarSectionHeader-background: #181818; + --vscode-sideBarSectionHeader-border: #2b2b2b; + --vscode-sideBarSectionHeader-foreground: #cccccc; + --vscode-sideBarStickyScroll-background: #181818; + --vscode-sideBarStickyScroll-shadow: #000000; + --vscode-sideBarTitle-background: #181818; + --vscode-sideBarTitle-foreground: #cccccc; + --vscode-sideBySideEditor-horizontalBorder: rgba(255, 255, 255, 0.09); + --vscode-sideBySideEditor-verticalBorder: rgba(255, 255, 255, 0.09); + --vscode-simpleFindWidget-sashBorder: #454545; + --vscode-statusBar-background: #181818; + --vscode-statusBar-border: #2b2b2b; + --vscode-statusBar-debuggingBackground: #0078d4; + --vscode-statusBar-debuggingBorder: #2b2b2b; + --vscode-statusBar-debuggingForeground: #ffffff; + --vscode-statusBar-focusBorder: #0078d4; + --vscode-statusBar-foreground: #cccccc; + --vscode-statusBar-noFolderBackground: #1f1f1f; + --vscode-statusBar-noFolderBorder: #2b2b2b; + --vscode-statusBar-noFolderForeground: #cccccc; + --vscode-statusBarItem-activeBackground: rgba(255, 255, 255, 0.18); + --vscode-statusBarItem-compactHoverBackground: rgba(255, 255, 255, 0.2); + --vscode-statusBarItem-errorBackground: #b91007; + --vscode-statusBarItem-errorForeground: #ffffff; + --vscode-statusBarItem-errorHoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-errorHoverForeground: #cccccc; + --vscode-statusBarItem-focusBorder: #0078d4; + --vscode-statusBarItem-hoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-hoverForeground: #cccccc; + --vscode-statusBarItem-offlineBackground: #6c1717; + --vscode-statusBarItem-offlineForeground: #ffffff; + --vscode-statusBarItem-offlineHoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-offlineHoverForeground: #cccccc; + --vscode-statusBarItem-prominentBackground: rgba(110, 118, 129, 0.4); + --vscode-statusBarItem-prominentForeground: #cccccc; + --vscode-statusBarItem-prominentHoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-prominentHoverForeground: #cccccc; + --vscode-statusBarItem-remoteBackground: #0078d4; + --vscode-statusBarItem-remoteForeground: #ffffff; + --vscode-statusBarItem-remoteHoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-remoteHoverForeground: #cccccc; + --vscode-statusBarItem-warningBackground: #7a6400; + --vscode-statusBarItem-warningForeground: #ffffff; + --vscode-statusBarItem-warningHoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-warningHoverForeground: #cccccc; + --vscode-symbolIcon-arrayForeground: #cccccc; + --vscode-symbolIcon-booleanForeground: #cccccc; + --vscode-symbolIcon-classForeground: #ee9d28; + --vscode-symbolIcon-colorForeground: #cccccc; + --vscode-symbolIcon-constantForeground: #cccccc; + --vscode-symbolIcon-constructorForeground: #b180d7; + --vscode-symbolIcon-enumeratorForeground: #ee9d28; + --vscode-symbolIcon-enumeratorMemberForeground: #75beff; + --vscode-symbolIcon-eventForeground: #ee9d28; + --vscode-symbolIcon-fieldForeground: #75beff; + --vscode-symbolIcon-fileForeground: #cccccc; + --vscode-symbolIcon-folderForeground: #cccccc; + --vscode-symbolIcon-functionForeground: #b180d7; + --vscode-symbolIcon-interfaceForeground: #75beff; + --vscode-symbolIcon-keyForeground: #cccccc; + --vscode-symbolIcon-keywordForeground: #cccccc; + --vscode-symbolIcon-methodForeground: #b180d7; + --vscode-symbolIcon-moduleForeground: #cccccc; + --vscode-symbolIcon-namespaceForeground: #cccccc; + --vscode-symbolIcon-nullForeground: #cccccc; + --vscode-symbolIcon-numberForeground: #cccccc; + --vscode-symbolIcon-objectForeground: #cccccc; + --vscode-symbolIcon-operatorForeground: #cccccc; + --vscode-symbolIcon-packageForeground: #cccccc; + --vscode-symbolIcon-propertyForeground: #cccccc; + --vscode-symbolIcon-referenceForeground: #cccccc; + --vscode-symbolIcon-snippetForeground: #cccccc; + --vscode-symbolIcon-stringForeground: #cccccc; + --vscode-symbolIcon-structForeground: #cccccc; + --vscode-symbolIcon-textForeground: #cccccc; + --vscode-symbolIcon-typeParameterForeground: #cccccc; + --vscode-symbolIcon-unitForeground: #cccccc; + --vscode-symbolIcon-variableForeground: #75beff; + --vscode-tab-activeBackground: #1f1f1f; + --vscode-tab-activeBorder: #1f1f1f; + --vscode-tab-activeBorderTop: #0078d4; + --vscode-tab-activeForeground: #ffffff; + --vscode-tab-activeModifiedBorder: #3399cc; + --vscode-tab-border: #2b2b2b; + --vscode-tab-dragAndDropBorder: #ffffff; + --vscode-tab-hoverBackground: #1f1f1f; + --vscode-tab-inactiveBackground: #181818; + --vscode-tab-inactiveForeground: #9d9d9d; + --vscode-tab-inactiveModifiedBorder: rgba(51, 153, 204, 0.5); + --vscode-tab-lastPinnedBorder: rgba(204, 204, 204, 0.2); + --vscode-tab-selectedBackground: #222222; + --vscode-tab-selectedBorderTop: #6caddf; + --vscode-tab-selectedForeground: rgba(255, 255, 255, 0.63); + --vscode-tab-unfocusedActiveBackground: #1f1f1f; + --vscode-tab-unfocusedActiveBorder: #1f1f1f; + --vscode-tab-unfocusedActiveBorderTop: #2b2b2b; + --vscode-tab-unfocusedActiveForeground: rgba(255, 255, 255, 0.5); + --vscode-tab-unfocusedActiveModifiedBorder: rgba(51, 153, 204, 0.5); + --vscode-tab-unfocusedHoverBackground: #1f1f1f; + --vscode-tab-unfocusedInactiveBackground: #181818; + --vscode-tab-unfocusedInactiveForeground: rgba(157, 157, 157, 0.5); + --vscode-tab-unfocusedInactiveModifiedBorder: rgba(51, 153, 204, 0.25); + --vscode-terminal-ansiBlack: #000000; + --vscode-terminal-ansiBlue: #2472c8; + --vscode-terminal-ansiBrightBlack: #666666; + --vscode-terminal-ansiBrightBlue: #3b8eea; + --vscode-terminal-ansiBrightCyan: #29b8db; + --vscode-terminal-ansiBrightGreen: #23d18b; + --vscode-terminal-ansiBrightMagenta: #d670d6; + --vscode-terminal-ansiBrightRed: #f14c4c; + --vscode-terminal-ansiBrightWhite: #e5e5e5; + --vscode-terminal-ansiBrightYellow: #f5f543; + --vscode-terminal-ansiCyan: #11a8cd; + --vscode-terminal-ansiGreen: #0dbc79; + --vscode-terminal-ansiMagenta: #bc3fbc; + --vscode-terminal-ansiRed: #cd3131; + --vscode-terminal-ansiWhite: #e5e5e5; + --vscode-terminal-ansiYellow: #e5e510; + --vscode-terminal-border: #2b2b2b; + --vscode-terminal-dropBackground: rgba(83, 89, 93, 0.5); + --vscode-terminal-findMatchBackground: #9e6a03; + --vscode-terminal-findMatchHighlightBackground: rgba(234, 92, 0, 0.33); + --vscode-terminal-foreground: #cccccc; + --vscode-terminal-hoverHighlightBackground: rgba(38, 79, 120, 0.13); + --vscode-terminal-inactiveSelectionBackground: #3a3d41; + --vscode-terminal-initialHintForeground: rgba(255, 255, 255, 0.34); + --vscode-terminal-selectionBackground: #264f78; + --vscode-terminal-tab.activeBorder: #0078d4; + --vscode-terminalCommandDecoration-defaultBackground: rgba(255, 255, 255, 0.25); + --vscode-terminalCommandDecoration-errorBackground: #f14c4c; + --vscode-terminalCommandDecoration-successBackground: #1b81a8; + --vscode-terminalCommandGuide-foreground: #37373d; + --vscode-terminalOverviewRuler-border: #010409; + --vscode-terminalOverviewRuler-cursorForeground: rgba(160, 160, 160, 0.8); + --vscode-terminalOverviewRuler-findMatchForeground: rgba(209, 134, 22, 0.49); + --vscode-terminalStickyScrollHover-background: #2a2d2e; + --vscode-terminalSymbolIcon-aliasForeground: #b180d7; + --vscode-terminalSymbolIcon-argumentForeground: #75beff; + --vscode-terminalSymbolIcon-fileForeground: #cccccc; + --vscode-terminalSymbolIcon-flagForeground: #ee9d28; + --vscode-terminalSymbolIcon-folderForeground: #cccccc; + --vscode-terminalSymbolIcon-methodForeground: #b180d7; + --vscode-terminalSymbolIcon-optionForeground: #ee9d28; + --vscode-terminalSymbolIcon-optionValueForeground: #75beff; + --vscode-testing-coverCountBadgeBackground: #616161; + --vscode-testing-coverCountBadgeForeground: #f8f8f8; + --vscode-testing-coveredBackground: rgba(156, 204, 44, 0.2); + --vscode-testing-coveredBorder: rgba(156, 204, 44, 0.15); + --vscode-testing-coveredGutterBackground: rgba(156, 204, 44, 0.12); + --vscode-testing-iconErrored: #f14c4c; + --vscode-testing-iconErrored.retired: rgba(241, 76, 76, 0.7); + --vscode-testing-iconFailed: #f14c4c; + --vscode-testing-iconFailed.retired: rgba(241, 76, 76, 0.7); + --vscode-testing-iconPassed: #73c991; + --vscode-testing-iconPassed.retired: rgba(115, 201, 145, 0.7); + --vscode-testing-iconQueued: #cca700; + --vscode-testing-iconQueued.retired: rgba(204, 167, 0, 0.7); + --vscode-testing-iconSkipped: #848484; + --vscode-testing-iconSkipped.retired: rgba(132, 132, 132, 0.7); + --vscode-testing-iconUnset: #848484; + --vscode-testing-iconUnset.retired: rgba(132, 132, 132, 0.7); + --vscode-testing-message.error.badgeBackground: #f14c4c; + --vscode-testing-message.error.badgeBorder: #f14c4c; + --vscode-testing-message.error.badgeForeground: #000000; + --vscode-testing-message.info.decorationForeground: rgba(204, 204, 204, 0.5); + --vscode-testing-messagePeekBorder: #3794ff; + --vscode-testing-messagePeekHeaderBackground: rgba(55, 148, 255, 0.1); + --vscode-testing-peekBorder: #f14c4c; + --vscode-testing-peekHeaderBackground: rgba(241, 76, 76, 0.1); + --vscode-testing-runAction: #73c991; + --vscode-testing-uncoveredBackground: rgba(255, 0, 0, 0.2); + --vscode-testing-uncoveredBorder: rgba(255, 0, 0, 0.15); + --vscode-testing-uncoveredBranchBackground: #781212; + --vscode-testing-uncoveredGutterBackground: rgba(255, 0, 0, 0.3); + --vscode-textBlockQuote-background: #2b2b2b; + --vscode-textBlockQuote-border: #616161; + --vscode-textCodeBlock-background: #2b2b2b; + --vscode-textLink-activeForeground: #4daafc; + --vscode-textLink-foreground: #4daafc; + --vscode-textPreformat-background: #3c3c3c; + --vscode-textPreformat-foreground: #d0d0d0; + --vscode-textSeparator-foreground: #21262d; + --vscode-titleBar-activeBackground: #181818; + --vscode-titleBar-activeForeground: #cccccc; + --vscode-titleBar-border: #2b2b2b; + --vscode-titleBar-inactiveBackground: #1f1f1f; + --vscode-titleBar-inactiveForeground: #9d9d9d; + --vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31); + --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); + --vscode-tree-inactiveIndentGuidesStroke: rgba(88, 88, 88, 0.4); + --vscode-tree-indentGuidesStroke: #585858; + --vscode-tree-tableColumnsBorder: rgba(204, 204, 204, 0.13); + --vscode-tree-tableOddRowsBackground: rgba(204, 204, 204, 0.04); + --vscode-walkThrough-embeddedEditorBackground: rgba(0, 0, 0, 0.4); + --vscode-walkthrough-stepTitle.foreground: #ffffff; + --vscode-welcomePage-progress.background: #313131; + --vscode-welcomePage-progress.foreground: #0078d4; + --vscode-welcomePage-tileBackground: #2b2b2b; + --vscode-welcomePage-tileBorder: rgba(255, 255, 255, 0.1); + --vscode-welcomePage-tileHoverBackground: #262626; + --vscode-widget-border: #313131; + --vscode-widget-shadow: rgba(0, 0, 0, 0.36); +} diff --git a/webview-ui/playwright/themes/vscode-theme-high-contrast-light.css b/webview-ui/playwright/themes/vscode-theme-high-contrast-light.css new file mode 100644 index 0000000000..ff3ba14d2c --- /dev/null +++ b/webview-ui/playwright/themes/vscode-theme-high-contrast-light.css @@ -0,0 +1,693 @@ +/* Generated from Default High Contrast Light by VS Code 1.100.0. Do not edit manually. */ +.vscode-high-contrast-light { + color-scheme: light; + --vscode-actionBar-toggledBackground: #dddddd; + --vscode-activityBar-activeBorder: #0f4a85; + --vscode-activityBar-activeFocusBorder: #b5200d; + --vscode-activityBar-background: #ffffff; + --vscode-activityBar-border: #0f4a85; + --vscode-activityBar-foreground: #292929; + --vscode-activityBar-inactiveForeground: #292929; + --vscode-activityBarBadge-background: #0f4a85; + --vscode-activityBarBadge-foreground: #ffffff; + --vscode-activityBarTop-activeBorder: #b5200d; + --vscode-activityBarTop-dropBorder: #292929; + --vscode-activityBarTop-foreground: #292929; + --vscode-activityBarTop-inactiveForeground: #292929; + --vscode-activityErrorBadge-background: #f14c4c; + --vscode-activityErrorBadge-foreground: #000000; + --vscode-activityWarningBadge-background: #cca700; + --vscode-activityWarningBadge-foreground: #000000; + --vscode-badge-background: #0f4a85; + --vscode-badge-foreground: #ffffff; + --vscode-banner-background: rgba(15, 74, 133, 0.1); + --vscode-banner-iconForeground: #1a85ff; + --vscode-breadcrumb-activeSelectionForeground: #2d2d2d; + --vscode-breadcrumb-background: #ffffff; + --vscode-breadcrumb-focusForeground: #2d2d2d; + --vscode-breadcrumb-foreground: rgba(41, 41, 41, 0.8); + --vscode-breadcrumbPicker-background: #ffffff; + --vscode-button-background: #0f4a85; + --vscode-button-border: #0f4a85; + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #0f4a85; + --vscode-button-secondaryBackground: #ffffff; + --vscode-button-secondaryForeground: #292929; + --vscode-button-separator: rgba(255, 255, 255, 0.4); + --vscode-chart-axis: #0f4a85; + --vscode-chart-guide: #0f4a85; + --vscode-chart-line: #236b8e; + --vscode-charts-blue: #1a85ff; + --vscode-charts-foreground: #292929; + --vscode-charts-green: #374e06; + --vscode-charts-lines: rgba(41, 41, 41, 0.5); + --vscode-charts-orange: #0f4a85; + --vscode-charts-purple: #652d90; + --vscode-charts-red: #b5200d; + --vscode-charts-yellow: #895503; + --vscode-chat-avatarBackground: #ffffff; + --vscode-chat-avatarForeground: #292929; + --vscode-chat-editedFileForeground: #895503; + --vscode-chat-requestBorder: #0f4a85; + --vscode-chat-slashCommandBackground: #0f4a85; + --vscode-chat-slashCommandForeground: #ffffff; + --vscode-checkbox-background: #ffffff; + --vscode-checkbox-border: #0f4a85; + --vscode-checkbox-disabled.background: #b8b8b8; + --vscode-checkbox-disabled.foreground: #6f6f6f; + --vscode-checkbox-foreground: #292929; + --vscode-checkbox-selectBackground: #ffffff; + --vscode-checkbox-selectBorder: #292929; + --vscode-commandCenter-activeBorder: #292929; + --vscode-commandCenter-activeForeground: #292929; + --vscode-commandCenter-border: #0f4a85; + --vscode-commandCenter-debuggingBackground: rgba(181, 32, 13, 0.26); + --vscode-commandCenter-foreground: #292929; + --vscode-commandCenter-inactiveBorder: rgba(41, 41, 41, 0.25); + --vscode-commandCenter-inactiveForeground: #292929; + --vscode-commentsView-resolvedIcon: #0f4a85; + --vscode-commentsView-unresolvedIcon: #0f4a85; + --vscode-contrastActiveBorder: #006bbd; + --vscode-contrastBorder: #0f4a85; + --vscode-debugConsole-errorForeground: #b5200d; + --vscode-debugConsole-infoForeground: #292929; + --vscode-debugConsole-sourceForeground: #292929; + --vscode-debugConsole-warningForeground: #895503; + --vscode-debugConsoleInputIcon-foreground: #292929; + --vscode-debugExceptionWidget-background: #f1dfde; + --vscode-debugExceptionWidget-border: #a31515; + --vscode-debugIcon-breakpointCurrentStackframeForeground: #be8700; + --vscode-debugIcon-breakpointDisabledForeground: #848484; + --vscode-debugIcon-breakpointForeground: #e51400; + --vscode-debugIcon-breakpointStackframeForeground: #89d185; + --vscode-debugIcon-breakpointUnverifiedForeground: #848484; + --vscode-debugIcon-continueForeground: #007acc; + --vscode-debugIcon-disconnectForeground: #a1260d; + --vscode-debugIcon-pauseForeground: #007acc; + --vscode-debugIcon-restartForeground: #388a34; + --vscode-debugIcon-startForeground: #388a34; + --vscode-debugIcon-stepBackForeground: #007acc; + --vscode-debugIcon-stepIntoForeground: #007acc; + --vscode-debugIcon-stepOutForeground: #007acc; + --vscode-debugIcon-stepOverForeground: #007acc; + --vscode-debugIcon-stopForeground: #a1260d; + --vscode-debugTokenExpression-boolean: #0000ff; + --vscode-debugTokenExpression-error: #e51400; + --vscode-debugTokenExpression-name: #292929; + --vscode-debugTokenExpression-number: #098658; + --vscode-debugTokenExpression-string: #a31515; + --vscode-debugTokenExpression-type: #292929; + --vscode-debugTokenExpression-value: #292929; + --vscode-debugToolBar-background: #ffffff; + --vscode-debugView-exceptionLabelBackground: #a31515; + --vscode-debugView-exceptionLabelForeground: #292929; + --vscode-debugView-stateLabelBackground: rgba(136, 136, 136, 0.27); + --vscode-debugView-stateLabelForeground: #292929; + --vscode-debugView-valueChangedHighlight: #569cd6; + --vscode-descriptionForeground: rgba(41, 41, 41, 0.7); + --vscode-diffEditor-border: #0f4a85; + --vscode-diffEditor-insertedTextBorder: #374e06; + --vscode-diffEditor-move.border: rgba(139, 139, 139, 0.61); + --vscode-diffEditor-moveActive.border: #ffa500; + --vscode-diffEditor-removedTextBorder: #ad0707; + --vscode-diffEditor-unchangedRegionBackground: #ffffff; + --vscode-diffEditor-unchangedRegionForeground: #292929; + --vscode-diffEditor-unchangedRegionShadow: rgba(115, 115, 115, 0.75); + --vscode-disabledForeground: #7f7f7f; + --vscode-dropdown-background: #ffffff; + --vscode-dropdown-border: #0f4a85; + --vscode-dropdown-foreground: #292929; + --vscode-dropdown-listBackground: #ffffff; + --vscode-editor-background: #ffffff; + --vscode-editor-compositionBorder: #000000; + --vscode-editor-findMatchBorder: #006bbd; + --vscode-editor-findMatchHighlightBorder: #006bbd; + --vscode-editor-findRangeHighlightBorder: rgba(0, 107, 189, 0.4); + --vscode-editor-focusedStackFrameHighlightBackground: rgba(206, 231, 206, 0.45); + --vscode-editor-font-size: 14px; + --vscode-editor-font-weight: normal; + --vscode-editor-foreground: #292929; + --vscode-editor-inactiveSelectionBackground: rgba(15, 74, 133, 0.5); + --vscode-editor-inlineValuesBackground: rgba(255, 200, 0, 0.2); + --vscode-editor-inlineValuesForeground: rgba(0, 0, 0, 0.5); + --vscode-editor-lineHighlightBorder: #0f4a85; + --vscode-editor-linkedEditingBackground: #ffffff; + --vscode-editor-rangeHighlightBorder: #006bbd; + --vscode-editor-selectionBackground: #0f4a85; + --vscode-editor-selectionForeground: #ffffff; + --vscode-editor-selectionHighlightBorder: #006bbd; + --vscode-editor-snippetFinalTabstopHighlightBorder: #292929; + --vscode-editor-snippetTabstopHighlightBackground: rgba(10, 50, 100, 0.2); + --vscode-editor-stackFrameHighlightBackground: rgba(255, 255, 102, 0.45); + --vscode-editor-symbolHighlightBorder: #006bbd; + --vscode-editor-wordHighlightBorder: #006bbd; + --vscode-editor-wordHighlightStrongBorder: #006bbd; + --vscode-editor-wordHighlightTextBorder: #006bbd; + --vscode-editorActionList-background: #ffffff; + --vscode-editorActionList-focusBackground: rgba(15, 74, 133, 0.1); + --vscode-editorActionList-foreground: #292929; + --vscode-editorActiveLineNumber-foreground: #006bbd; + --vscode-editorBracketHighlight-foreground1: #0431fa; + --vscode-editorBracketHighlight-foreground2: #319331; + --vscode-editorBracketHighlight-foreground3: #7b3814; + --vscode-editorBracketHighlight-foreground4: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-foreground5: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-foreground6: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-unexpectedBracket.foreground: #b5200d; + --vscode-editorBracketMatch-background: rgba(0, 0, 0, 0); + --vscode-editorBracketMatch-border: #0f4a85; + --vscode-editorBracketPairGuide-activeBackground1: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground2: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground3: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground4: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground5: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground6: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background1: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background2: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background3: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background4: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background5: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background6: rgba(0, 0, 0, 0); + --vscode-editorCodeLens-foreground: #292929; + --vscode-editorCommentsWidget-rangeActiveBackground: rgba(15, 74, 133, 0.1); + --vscode-editorCommentsWidget-rangeBackground: rgba(15, 74, 133, 0.1); + --vscode-editorCommentsWidget-replyInputBackground: #ffffff; + --vscode-editorCommentsWidget-resolvedBorder: #0f4a85; + --vscode-editorCommentsWidget-unresolvedBorder: #0f4a85; + --vscode-editorCursor-foreground: #0f4a85; + --vscode-editorError-border: #b5200d; + --vscode-editorError-foreground: #b5200d; + --vscode-editorGhostText-border: rgba(41, 41, 41, 0.8); + --vscode-editorGroup-border: #0f4a85; + --vscode-editorGroup-dropBackground: rgba(15, 74, 133, 0.5); + --vscode-editorGroup-dropIntoPromptBackground: #ffffff; + --vscode-editorGroup-dropIntoPromptBorder: #0f4a85; + --vscode-editorGroup-dropIntoPromptForeground: #292929; + --vscode-editorGroup-focusedEmptyBorder: #006bbd; + --vscode-editorGroupHeader-border: #0f4a85; + --vscode-editorGroupHeader-noTabsBackground: #ffffff; + --vscode-editorGutter-addedBackground: #48985d; + --vscode-editorGutter-addedSecondaryBackground: #48985d; + --vscode-editorGutter-background: #ffffff; + --vscode-editorGutter-commentGlyphForeground: #ffffff; + --vscode-editorGutter-commentRangeForeground: #000000; + --vscode-editorGutter-commentUnresolvedGlyphForeground: #ffffff; + --vscode-editorGutter-deletedBackground: #b5200d; + --vscode-editorGutter-deletedSecondaryBackground: #b5200d; + --vscode-editorGutter-foldingControlForeground: #292929; + --vscode-editorGutter-itemBackground: #000000; + --vscode-editorGutter-itemGlyphForeground: #ffffff; + --vscode-editorGutter-modifiedBackground: #2090d3; + --vscode-editorGutter-modifiedSecondaryBackground: #2090d3; + --vscode-editorHint-border: #292929; + --vscode-editorHoverWidget-background: #ffffff; + --vscode-editorHoverWidget-border: #0f4a85; + --vscode-editorHoverWidget-foreground: #292929; + --vscode-editorHoverWidget-highlightForeground: #006bbd; + --vscode-editorHoverWidget-statusBarBackground: #ffffff; + --vscode-editorIndentGuide-activeBackground: #cccccc; + --vscode-editorIndentGuide-activeBackground1: #cccccc; + --vscode-editorIndentGuide-activeBackground2: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground3: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground4: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground5: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground6: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background: #cccccc; + --vscode-editorIndentGuide-background1: #cccccc; + --vscode-editorIndentGuide-background2: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background3: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background4: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background5: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background6: rgba(0, 0, 0, 0); + --vscode-editorInfo-border: #292929; + --vscode-editorInfo-foreground: #1a85ff; + --vscode-editorInlayHint-background: rgba(15, 74, 133, 0.1); + --vscode-editorInlayHint-foreground: #000000; + --vscode-editorInlayHint-parameterBackground: rgba(15, 74, 133, 0.1); + --vscode-editorInlayHint-parameterForeground: #000000; + --vscode-editorInlayHint-typeBackground: rgba(15, 74, 133, 0.1); + --vscode-editorInlayHint-typeForeground: #000000; + --vscode-editorLightBulb-foreground: #007acc; + --vscode-editorLightBulbAi-foreground: #007acc; + --vscode-editorLightBulbAutoFix-foreground: #007acc; + --vscode-editorLineNumber-activeForeground: #006bbd; + --vscode-editorLineNumber-foreground: #292929; + --vscode-editorLink-activeForeground: #292929; + --vscode-editorMarkerNavigation-background: #ffffff; + --vscode-editorMarkerNavigationError-background: #0f4a85; + --vscode-editorMarkerNavigationInfo-background: #0f4a85; + --vscode-editorMarkerNavigationWarning-background: #0f4a85; + --vscode-editorMarkerNavigationWarning-headerBackground: rgba(15, 74, 133, 0.2); + --vscode-editorMultiCursor-primary.foreground: #0f4a85; + --vscode-editorMultiCursor-secondary.foreground: #0f4a85; + --vscode-editorOverviewRuler-addedForeground: rgba(72, 152, 93, 0.6); + --vscode-editorOverviewRuler-border: #666666; + --vscode-editorOverviewRuler-bracketMatchForeground: #a0a0a0; + --vscode-editorOverviewRuler-commentForeground: #000000; + --vscode-editorOverviewRuler-commentUnresolvedForeground: #000000; + --vscode-editorOverviewRuler-commonContentForeground: #007acc; + --vscode-editorOverviewRuler-currentContentForeground: #007acc; + --vscode-editorOverviewRuler-deletedForeground: rgba(181, 32, 13, 0.6); + --vscode-editorOverviewRuler-errorForeground: #b5200d; + --vscode-editorOverviewRuler-findMatchForeground: #ab5a00; + --vscode-editorOverviewRuler-incomingContentForeground: #007acc; + --vscode-editorOverviewRuler-infoForeground: #292929; + --vscode-editorOverviewRuler-modifiedForeground: rgba(32, 144, 211, 0.6); + --vscode-editorOverviewRuler-rangeHighlightForeground: rgba(0, 122, 204, 0.6); + --vscode-editorOverviewRuler-selectionHighlightForeground: rgba(160, 160, 160, 0.8); + --vscode-editorOverviewRuler-warningForeground: rgba(255, 204, 0, 0.8); + --vscode-editorOverviewRuler-wordHighlightForeground: rgba(160, 160, 160, 0.8); + --vscode-editorOverviewRuler-wordHighlightStrongForeground: rgba(192, 160, 192, 0.8); + --vscode-editorOverviewRuler-wordHighlightTextForeground: rgba(160, 160, 160, 0.8); + --vscode-editorPane-background: #ffffff; + --vscode-editorRuler-foreground: #292929; + --vscode-editorStickyScroll-background: #ffffff; + --vscode-editorStickyScroll-border: #0f4a85; + --vscode-editorStickyScrollHover-background: rgba(15, 74, 133, 0.1); + --vscode-editorSuggestWidget-background: #ffffff; + --vscode-editorSuggestWidget-border: #0f4a85; + --vscode-editorSuggestWidget-focusHighlightForeground: #006bbd; + --vscode-editorSuggestWidget-foreground: #292929; + --vscode-editorSuggestWidget-highlightForeground: #006bbd; + --vscode-editorSuggestWidgetStatus-foreground: rgba(41, 41, 41, 0.5); + --vscode-editorUnicodeHighlight-border: #895503; + --vscode-editorUnnecessaryCode-border: #0f4a85; + --vscode-editorWarning-border: rgba(255, 204, 0, 0.8); + --vscode-editorWarning-foreground: #895503; + --vscode-editorWatermark-foreground: #292929; + --vscode-editorWhitespace-foreground: #cccccc; + --vscode-editorWidget-background: #ffffff; + --vscode-editorWidget-border: #0f4a85; + --vscode-editorWidget-foreground: #292929; + --vscode-errorForeground: #b5200d; + --vscode-extensionBadge-remoteBackground: #0f4a85; + --vscode-extensionBadge-remoteForeground: #ffffff; + --vscode-extensionButton-separator: rgba(255, 255, 255, 0.4); + --vscode-extensionIcon-preReleaseForeground: #0f4a85; + --vscode-extensionIcon-privateForeground: rgba(0, 0, 0, 0.38); + --vscode-extensionIcon-sponsorForeground: #b51e78; + --vscode-extensionIcon-starForeground: #0f4a85; + --vscode-extensionIcon-verifiedForeground: #0f4a85; + --vscode-focusBorder: #006bbd; + --vscode-font-size: 13px; + --vscode-font-weight: normal; + --vscode-foreground: #292929; + --vscode-gauge-background: #0f4a85; + --vscode-gauge-border: #0f4a85; + --vscode-gauge-errorBackground: #0f4a85; + --vscode-gauge-errorForeground: #ffffff; + --vscode-gauge-foreground: #ffffff; + --vscode-gauge-warningBackground: #0f4a85; + --vscode-gauge-warningForeground: #ffffff; + --vscode-git-blame.editorDecorationForeground: #000000; + --vscode-gitDecoration-addedResourceForeground: #374e06; + --vscode-gitDecoration-conflictingResourceForeground: #ad0707; + --vscode-gitDecoration-deletedResourceForeground: #ad0707; + --vscode-gitDecoration-ignoredResourceForeground: #8e8e90; + --vscode-gitDecoration-modifiedResourceForeground: #895503; + --vscode-gitDecoration-renamedResourceForeground: #007100; + --vscode-gitDecoration-stageDeletedResourceForeground: #ad0707; + --vscode-gitDecoration-stageModifiedResourceForeground: #895503; + --vscode-gitDecoration-submoduleResourceForeground: #1258a7; + --vscode-gitDecoration-untrackedResourceForeground: #007100; + --vscode-icon-foreground: #292929; + --vscode-inlineChat-background: #ffffff; + --vscode-inlineChat-border: #0f4a85; + --vscode-inlineChat-foreground: #292929; + --vscode-inlineChatInput-background: #ffffff; + --vscode-inlineChatInput-border: #0f4a85; + --vscode-inlineChatInput-focusBorder: #006bbd; + --vscode-inlineChatInput-placeholderForeground: rgba(41, 41, 41, 0.7); + --vscode-inlineEdit-gutterIndicator.primaryBackground: rgba(15, 74, 133, 0.5); + --vscode-inlineEdit-gutterIndicator.primaryBorder: #0f4a85; + --vscode-inlineEdit-gutterIndicator.primaryForeground: #ffffff; + --vscode-inlineEdit-gutterIndicator.secondaryBackground: #ffffff; + --vscode-inlineEdit-gutterIndicator.secondaryBorder: #ffffff; + --vscode-inlineEdit-gutterIndicator.secondaryForeground: #292929; + --vscode-inlineEdit-gutterIndicator.successfulBackground: #0f4a85; + --vscode-inlineEdit-gutterIndicator.successfulBorder: #0f4a85; + --vscode-inlineEdit-gutterIndicator.successfulForeground: #ffffff; + --vscode-input-background: #ffffff; + --vscode-input-border: #0f4a85; + --vscode-input-foreground: #292929; + --vscode-input-placeholderForeground: rgba(41, 41, 41, 0.7); + --vscode-inputOption-activeBackground: rgba(0, 0, 0, 0); + --vscode-inputOption-activeBorder: #0f4a85; + --vscode-inputOption-activeForeground: #292929; + --vscode-inputValidation-errorBackground: #ffffff; + --vscode-inputValidation-errorBorder: #0f4a85; + --vscode-inputValidation-errorForeground: #292929; + --vscode-inputValidation-infoBackground: #ffffff; + --vscode-inputValidation-infoBorder: #0f4a85; + --vscode-inputValidation-infoForeground: #292929; + --vscode-inputValidation-warningBackground: #ffffff; + --vscode-inputValidation-warningBorder: #0f4a85; + --vscode-inputValidation-warningForeground: #292929; + --vscode-interactive-activeCodeBorder: #0f4a85; + --vscode-interactive-inactiveCodeBorder: #0f4a85; + --vscode-keybindingLabel-background: rgba(0, 0, 0, 0); + --vscode-keybindingLabel-border: #0f4a85; + --vscode-keybindingLabel-bottomBorder: #292929; + --vscode-keybindingLabel-foreground: #292929; + --vscode-list-activeSelectionBackground: rgba(15, 74, 133, 0.1); + --vscode-list-deemphasizedForeground: #666666; + --vscode-list-filterMatchBorder: #006bbd; + --vscode-list-focusHighlightForeground: #006bbd; + --vscode-list-focusOutline: #006bbd; + --vscode-list-highlightForeground: #006bbd; + --vscode-list-hoverBackground: rgba(15, 74, 133, 0.1); + --vscode-list-inactiveSelectionBackground: rgba(15, 74, 133, 0.1); + --vscode-list-invalidItemForeground: #b5200d; + --vscode-listFilterWidget-background: #ffffff; + --vscode-listFilterWidget-noMatchesOutline: #0f4a85; + --vscode-listFilterWidget-outline: #007acc; + --vscode-menu-background: #ffffff; + --vscode-menu-border: #0f4a85; + --vscode-menu-foreground: #292929; + --vscode-menu-selectionBackground: rgba(15, 74, 133, 0.1); + --vscode-menu-selectionBorder: #006bbd; + --vscode-menu-separatorBackground: #0f4a85; + --vscode-menubar-selectionBorder: #006bbd; + --vscode-menubar-selectionForeground: #292929; + --vscode-merge-border: #007acc; + --vscode-mergeEditor-change.background: rgba(155, 185, 85, 0.2); + --vscode-mergeEditor-change.word.background: rgba(156, 204, 44, 0.4); + --vscode-mergeEditor-changeBase.background: #ffcccc; + --vscode-mergeEditor-changeBase.word.background: #ffa3a3; + --vscode-mergeEditor-conflict.handled.minimapOverViewRuler: rgba(173, 172, 168, 0.93); + --vscode-mergeEditor-conflict.handledFocused.border: rgba(193, 193, 193, 0.8); + --vscode-mergeEditor-conflict.handledUnfocused.border: rgba(134, 134, 134, 0.29); + --vscode-mergeEditor-conflict.unhandled.minimapOverViewRuler: #fcba03; + --vscode-mergeEditor-conflict.unhandledFocused.border: #ffa600; + --vscode-mergeEditor-conflict.unhandledUnfocused.border: rgba(255, 166, 0, 0.48); + --vscode-mergeEditor-conflictingLines.background: rgba(255, 234, 0, 0.28); + --vscode-minimap-chatEditHighlight: rgba(255, 255, 255, 0.6); + --vscode-minimap-errorHighlight: #b5200d; + --vscode-minimap-findMatchHighlight: #0f4a85; + --vscode-minimap-foregroundOpacity: #000000; + --vscode-minimap-infoHighlight: #292929; + --vscode-minimap-selectionHighlight: #0f4a85; + --vscode-minimap-selectionOccurrenceHighlight: #0f4a85; + --vscode-minimap-warningHighlight: rgba(255, 204, 0, 0.8); + --vscode-minimapGutter-addedBackground: #48985d; + --vscode-minimapGutter-deletedBackground: #b5200d; + --vscode-minimapGutter-modifiedBackground: #2090d3; + --vscode-minimapSlider-activeBackground: rgba(15, 74, 133, 0.5); + --vscode-minimapSlider-background: rgba(15, 74, 133, 0.2); + --vscode-minimapSlider-hoverBackground: rgba(15, 74, 133, 0.4); + --vscode-multiDiffEditor-background: #ffffff; + --vscode-multiDiffEditor-border: #cccccc; + --vscode-notebook-cellBorderColor: #0f4a85; + --vscode-notebook-cellInsertionIndicator: #006bbd; + --vscode-notebook-cellStatusBarItemHoverBackground: rgba(0, 0, 0, 0.08); + --vscode-notebook-cellToolbarSeparator: #0f4a85; + --vscode-notebook-focusedCellBorder: #006bbd; + --vscode-notebook-focusedEditorBorder: #006bbd; + --vscode-notebook-inactiveFocusedCellBorder: #0f4a85; + --vscode-notebook-inactiveSelectedCellBorder: #006bbd; + --vscode-notebook-selectedCellBorder: #0f4a85; + --vscode-notebookEditorOverviewRuler-runningCellForeground: #388a34; + --vscode-notebookScrollbarSlider-activeBackground: #0f4a85; + --vscode-notebookScrollbarSlider-background: rgba(15, 74, 133, 0.4); + --vscode-notebookScrollbarSlider-hoverBackground: rgba(15, 74, 133, 0.8); + --vscode-notebookStatusErrorIcon-foreground: #b5200d; + --vscode-notebookStatusRunningIcon-foreground: #292929; + --vscode-notebookStatusSuccessIcon-foreground: #388a34; + --vscode-notificationCenter-border: #0f4a85; + --vscode-notificationCenterHeader-background: #ffffff; + --vscode-notificationLink-foreground: #0f4a85; + --vscode-notificationToast-border: #0f4a85; + --vscode-notifications-background: #ffffff; + --vscode-notifications-border: #ffffff; + --vscode-notifications-foreground: #292929; + --vscode-notificationsErrorIcon-foreground: #b5200d; + --vscode-notificationsInfoIcon-foreground: #1a85ff; + --vscode-notificationsWarningIcon-foreground: #895503; + --vscode-panel-background: #ffffff; + --vscode-panel-border: #0f4a85; + --vscode-panel-dropBorder: #292929; + --vscode-panelInput-border: #0f4a85; + --vscode-panelSection-border: #0f4a85; + --vscode-panelSection-dropBackground: rgba(15, 74, 133, 0.5); + --vscode-panelSectionHeader-border: #0f4a85; + --vscode-panelStickyScroll-background: #ffffff; + --vscode-panelTitle-activeBorder: #b5200d; + --vscode-panelTitle-activeForeground: #292929; + --vscode-panelTitle-border: #0f4a85; + --vscode-panelTitle-inactiveForeground: #292929; + --vscode-panelTitleBadge-background: #0f4a85; + --vscode-panelTitleBadge-foreground: #ffffff; + --vscode-peekView-border: #0f4a85; + --vscode-peekViewEditor-background: #ffffff; + --vscode-peekViewEditor-matchHighlightBorder: #006bbd; + --vscode-peekViewEditorGutter-background: #ffffff; + --vscode-peekViewEditorStickyScroll-background: #ffffff; + --vscode-peekViewResult-background: #ffffff; + --vscode-peekViewResult-fileForeground: #292929; + --vscode-peekViewResult-lineForeground: #292929; + --vscode-peekViewResult-selectionForeground: #292929; + --vscode-peekViewTitle-background: #ffffff; + --vscode-peekViewTitleDescription-foreground: #292929; + --vscode-peekViewTitleLabel-foreground: #292929; + --vscode-pickerGroup-border: #0f4a85; + --vscode-pickerGroup-foreground: #0f4a85; + --vscode-ports-iconRunningProcessForeground: #ffffff; + --vscode-problemsErrorIcon-foreground: #b5200d; + --vscode-problemsInfoIcon-foreground: #1a85ff; + --vscode-problemsWarningIcon-foreground: #895503; + --vscode-profileBadge-background: #000000; + --vscode-profileBadge-foreground: #ffffff; + --vscode-profiles-sashBorder: #0f4a85; + --vscode-progressBar-background: #0f4a85; + --vscode-prompt-frontMatter.background: #0f4a85; + --vscode-prompt-frontMatter.inactiveBackground: #0f4a85; + --vscode-quickInput-background: #ffffff; + --vscode-quickInput-foreground: #292929; + --vscode-quickInputTitle-background: #ffffff; + --vscode-radio-activeBackground: rgba(0, 0, 0, 0); + --vscode-radio-activeBorder: #0f4a85; + --vscode-radio-activeForeground: #292929; + --vscode-radio-inactiveBorder: rgba(41, 41, 41, 0.2); + --vscode-sash-hoverBorder: #006bbd; + --vscode-scmGraph-foreground1: #ffb000; + --vscode-scmGraph-foreground2: #dc267f; + --vscode-scmGraph-foreground3: #994f00; + --vscode-scmGraph-foreground4: #40b0a6; + --vscode-scmGraph-foreground5: #b66dff; + --vscode-scmGraph-historyItemBaseRefColor: #ea5c00; + --vscode-scmGraph-historyItemHoverAdditionsForeground: #374e06; + --vscode-scmGraph-historyItemHoverDefaultLabelBackground: #0f4a85; + --vscode-scmGraph-historyItemHoverDefaultLabelForeground: #292929; + --vscode-scmGraph-historyItemHoverDeletionsForeground: #ad0707; + --vscode-scmGraph-historyItemHoverLabelForeground: #ffffff; + --vscode-scmGraph-historyItemRefColor: #1a85ff; + --vscode-scmGraph-historyItemRemoteRefColor: #652d90; + --vscode-scrollbarSlider-activeBackground: #0f4a85; + --vscode-scrollbarSlider-background: rgba(15, 74, 133, 0.4); + --vscode-scrollbarSlider-hoverBackground: rgba(15, 74, 133, 0.8); + --vscode-search-resultsInfoForeground: #292929; + --vscode-searchEditor-findMatchBorder: #006bbd; + --vscode-searchEditor-textInputBorder: #0f4a85; + --vscode-settings-checkboxBackground: #ffffff; + --vscode-settings-checkboxBorder: #0f4a85; + --vscode-settings-checkboxForeground: #292929; + --vscode-settings-dropdownBackground: #ffffff; + --vscode-settings-dropdownBorder: #0f4a85; + --vscode-settings-dropdownForeground: #292929; + --vscode-settings-dropdownListBorder: #0f4a85; + --vscode-settings-focusedRowBorder: #006bbd; + --vscode-settings-headerBorder: #0f4a85; + --vscode-settings-headerForeground: #292929; + --vscode-settings-modifiedItemIndicator: #66afe0; + --vscode-settings-numberInputBackground: #ffffff; + --vscode-settings-numberInputBorder: #0f4a85; + --vscode-settings-numberInputForeground: #292929; + --vscode-settings-sashBorder: #0f4a85; + --vscode-settings-settingsHeaderHoverForeground: rgba(41, 41, 41, 0.7); + --vscode-settings-textInputBackground: #ffffff; + --vscode-settings-textInputBorder: #0f4a85; + --vscode-settings-textInputForeground: #292929; + --vscode-sideBar-background: #ffffff; + --vscode-sideBar-border: #0f4a85; + --vscode-sideBar-dropBackground: rgba(15, 74, 133, 0.5); + --vscode-sideBarActivityBarTop-border: #0f4a85; + --vscode-sideBarSectionHeader-border: #0f4a85; + --vscode-sideBarStickyScroll-background: #ffffff; + --vscode-sideBarTitle-background: #ffffff; + --vscode-sideBarTitle-border: #0f4a85; + --vscode-sideBySideEditor-horizontalBorder: #0f4a85; + --vscode-sideBySideEditor-verticalBorder: #0f4a85; + --vscode-simpleFindWidget-sashBorder: #0f4a85; + --vscode-statusBar-border: #0f4a85; + --vscode-statusBar-debuggingBackground: #b5200d; + --vscode-statusBar-debuggingBorder: #0f4a85; + --vscode-statusBar-debuggingForeground: #ffffff; + --vscode-statusBar-focusBorder: #292929; + --vscode-statusBar-foreground: #292929; + --vscode-statusBar-noFolderBorder: #0f4a85; + --vscode-statusBar-noFolderForeground: #292929; + --vscode-statusBarItem-activeBackground: rgba(0, 0, 0, 0.18); + --vscode-statusBarItem-compactHoverBackground: rgba(0, 0, 0, 0.2); + --vscode-statusBarItem-errorBackground: #b5200d; + --vscode-statusBarItem-errorForeground: #ffffff; + --vscode-statusBarItem-errorHoverBackground: rgba(0, 0, 0, 0.12); + --vscode-statusBarItem-errorHoverForeground: #292929; + --vscode-statusBarItem-focusBorder: #006bbd; + --vscode-statusBarItem-hoverBackground: rgba(0, 0, 0, 0.12); + --vscode-statusBarItem-hoverForeground: #292929; + --vscode-statusBarItem-offlineBackground: #6c1717; + --vscode-statusBarItem-offlineForeground: #000000; + --vscode-statusBarItem-offlineHoverForeground: #292929; + --vscode-statusBarItem-prominentBackground: rgba(0, 0, 0, 0.5); + --vscode-statusBarItem-prominentForeground: #292929; + --vscode-statusBarItem-prominentHoverBackground: rgba(0, 0, 0, 0.12); + --vscode-statusBarItem-prominentHoverForeground: #292929; + --vscode-statusBarItem-remoteBackground: #ffffff; + --vscode-statusBarItem-remoteForeground: #000000; + --vscode-statusBarItem-remoteHoverForeground: #292929; + --vscode-statusBarItem-warningBackground: #895503; + --vscode-statusBarItem-warningForeground: #ffffff; + --vscode-statusBarItem-warningHoverBackground: rgba(0, 0, 0, 0.12); + --vscode-statusBarItem-warningHoverForeground: #292929; + --vscode-symbolIcon-arrayForeground: #292929; + --vscode-symbolIcon-booleanForeground: #292929; + --vscode-symbolIcon-classForeground: #d67e00; + --vscode-symbolIcon-colorForeground: #292929; + --vscode-symbolIcon-constantForeground: #292929; + --vscode-symbolIcon-constructorForeground: #652d90; + --vscode-symbolIcon-enumeratorForeground: #d67e00; + --vscode-symbolIcon-enumeratorMemberForeground: #007acc; + --vscode-symbolIcon-eventForeground: #d67e00; + --vscode-symbolIcon-fieldForeground: #007acc; + --vscode-symbolIcon-fileForeground: #292929; + --vscode-symbolIcon-folderForeground: #292929; + --vscode-symbolIcon-functionForeground: #652d90; + --vscode-symbolIcon-interfaceForeground: #007acc; + --vscode-symbolIcon-keyForeground: #292929; + --vscode-symbolIcon-keywordForeground: #292929; + --vscode-symbolIcon-methodForeground: #652d90; + --vscode-symbolIcon-moduleForeground: #292929; + --vscode-symbolIcon-namespaceForeground: #292929; + --vscode-symbolIcon-nullForeground: #292929; + --vscode-symbolIcon-numberForeground: #292929; + --vscode-symbolIcon-objectForeground: #292929; + --vscode-symbolIcon-operatorForeground: #292929; + --vscode-symbolIcon-packageForeground: #292929; + --vscode-symbolIcon-propertyForeground: #292929; + --vscode-symbolIcon-referenceForeground: #292929; + --vscode-symbolIcon-snippetForeground: #292929; + --vscode-symbolIcon-stringForeground: #292929; + --vscode-symbolIcon-structForeground: #292929; + --vscode-symbolIcon-textForeground: #292929; + --vscode-symbolIcon-typeParameterForeground: #292929; + --vscode-symbolIcon-unitForeground: #292929; + --vscode-symbolIcon-variableForeground: #007acc; + --vscode-tab-activeBackground: #ffffff; + --vscode-tab-activeBorderTop: #b5200d; + --vscode-tab-activeForeground: #292929; + --vscode-tab-activeModifiedBorder: #0f4a85; + --vscode-tab-border: #0f4a85; + --vscode-tab-dragAndDropBorder: #006bbd; + --vscode-tab-inactiveForeground: #292929; + --vscode-tab-inactiveModifiedBorder: #0f4a85; + --vscode-tab-lastPinnedBorder: #0f4a85; + --vscode-tab-selectedBackground: #ffffff; + --vscode-tab-selectedBorderTop: #b5200d; + --vscode-tab-selectedForeground: #292929; + --vscode-tab-unfocusedActiveBackground: #ffffff; + --vscode-tab-unfocusedActiveBorderTop: #b5200d; + --vscode-tab-unfocusedActiveForeground: #292929; + --vscode-tab-unfocusedActiveModifiedBorder: #0f4a85; + --vscode-tab-unfocusedHoverBorder: #0f4a85; + --vscode-tab-unfocusedInactiveForeground: #292929; + --vscode-tab-unfocusedInactiveModifiedBorder: #0f4a85; + --vscode-terminal-ansiBlack: #292929; + --vscode-terminal-ansiBlue: #0451a5; + --vscode-terminal-ansiBrightBlack: #666666; + --vscode-terminal-ansiBrightBlue: #0451a5; + --vscode-terminal-ansiBrightCyan: #0598bc; + --vscode-terminal-ansiBrightGreen: #00bc00; + --vscode-terminal-ansiBrightMagenta: #bc05bc; + --vscode-terminal-ansiBrightRed: #cd3131; + --vscode-terminal-ansiBrightWhite: #a5a5a5; + --vscode-terminal-ansiBrightYellow: #b5ba00; + --vscode-terminal-ansiCyan: #0598bc; + --vscode-terminal-ansiGreen: #136c13; + --vscode-terminal-ansiMagenta: #bc05bc; + --vscode-terminal-ansiRed: #cd3131; + --vscode-terminal-ansiWhite: #555555; + --vscode-terminal-ansiYellow: #949800; + --vscode-terminal-border: #0f4a85; + --vscode-terminal-dropBackground: rgba(15, 74, 133, 0.5); + --vscode-terminal-findMatchBackground: #0f4a85; + --vscode-terminal-findMatchBorder: #0f4a85; + --vscode-terminal-findMatchHighlightBorder: #0f4a85; + --vscode-terminal-foreground: #292929; + --vscode-terminal-inactiveSelectionBackground: rgba(15, 74, 133, 0.5); + --vscode-terminal-selectionBackground: #0f4a85; + --vscode-terminal-selectionForeground: #ffffff; + --vscode-terminalCommandDecoration-defaultBackground: rgba(0, 0, 0, 0.25); + --vscode-terminalCommandDecoration-errorBackground: #b5200d; + --vscode-terminalCommandDecoration-successBackground: #007100; + --vscode-terminalCommandGuide-foreground: #0f4a85; + --vscode-terminalOverviewRuler-border: #666666; + --vscode-terminalOverviewRuler-cursorForeground: rgba(160, 160, 160, 0.8); + --vscode-terminalOverviewRuler-findMatchForeground: #0f4a85; + --vscode-terminalStickyScroll-border: #0f4a85; + --vscode-terminalStickyScrollHover-background: #0f4a85; + --vscode-terminalSymbolIcon-aliasForeground: #652d90; + --vscode-terminalSymbolIcon-argumentForeground: #007acc; + --vscode-terminalSymbolIcon-fileForeground: #292929; + --vscode-terminalSymbolIcon-flagForeground: #d67e00; + --vscode-terminalSymbolIcon-folderForeground: #292929; + --vscode-terminalSymbolIcon-methodForeground: #652d90; + --vscode-terminalSymbolIcon-optionForeground: #d67e00; + --vscode-terminalSymbolIcon-optionValueForeground: #007acc; + --vscode-testing-coverCountBadgeBackground: #0f4a85; + --vscode-testing-coverCountBadgeForeground: #ffffff; + --vscode-testing-coveredBorder: #0f4a85; + --vscode-testing-coveredGutterBackground: #374e06; + --vscode-testing-iconErrored: #b5200d; + --vscode-testing-iconErrored.retired: rgba(181, 32, 13, 0.7); + --vscode-testing-iconFailed: #b5200d; + --vscode-testing-iconFailed.retired: rgba(181, 32, 13, 0.7); + --vscode-testing-iconPassed: #007100; + --vscode-testing-iconPassed.retired: rgba(0, 113, 0, 0.7); + --vscode-testing-iconQueued: #cca700; + --vscode-testing-iconQueued.retired: rgba(204, 167, 0, 0.7); + --vscode-testing-iconSkipped: #848484; + --vscode-testing-iconSkipped.retired: rgba(132, 132, 132, 0.7); + --vscode-testing-iconUnset: #848484; + --vscode-testing-iconUnset.retired: rgba(132, 132, 132, 0.7); + --vscode-testing-message.error.badgeBackground: #f14c4c; + --vscode-testing-message.error.badgeBorder: #f14c4c; + --vscode-testing-message.error.badgeForeground: #000000; + --vscode-testing-message.info.decorationForeground: rgba(41, 41, 41, 0.5); + --vscode-testing-messagePeekBorder: #0f4a85; + --vscode-testing-peekBorder: #0f4a85; + --vscode-testing-runAction: #007100; + --vscode-testing-uncoveredBorder: #0f4a85; + --vscode-testing-uncoveredGutterBackground: #b5200d; + --vscode-textBlockQuote-background: #f2f2f2; + --vscode-textBlockQuote-border: #292929; + --vscode-textCodeBlock-background: #f2f2f2; + --vscode-textLink-activeForeground: #0f4a85; + --vscode-textLink-foreground: #0f4a85; + --vscode-textPreformat-background: #09345f; + --vscode-textPreformat-foreground: #ffffff; + --vscode-textSeparator-foreground: #292929; + --vscode-titleBar-activeBackground: #ffffff; + --vscode-titleBar-activeForeground: #292929; + --vscode-titleBar-border: #0f4a85; + --vscode-titleBar-inactiveForeground: #292929; + --vscode-toolbar-hoverOutline: #006bbd; + --vscode-tree-inactiveIndentGuidesStroke: rgba(165, 165, 165, 0.4); + --vscode-tree-indentGuidesStroke: #a5a5a5; + --vscode-welcomePage-progress.background: #ffffff; + --vscode-welcomePage-progress.foreground: #0f4a85; + --vscode-welcomePage-tileBackground: #ffffff; + --vscode-welcomePage-tileBorder: #0f4a85; + --vscode-widget-border: #0f4a85; + --vscode-window-activeBorder: #0f4a85; + --vscode-window-inactiveBorder: #0f4a85; +} diff --git a/webview-ui/playwright/themes/vscode-theme-high-contrast.css b/webview-ui/playwright/themes/vscode-theme-high-contrast.css new file mode 100644 index 0000000000..8984353528 --- /dev/null +++ b/webview-ui/playwright/themes/vscode-theme-high-contrast.css @@ -0,0 +1,655 @@ +/* Generated from Default High Contrast by VS Code 1.100.0. Do not edit manually. */ +.vscode-high-contrast { + color-scheme: dark; + --vscode-actionBar-toggledBackground: #383a49; + --vscode-activityBar-activeBorder: #6fc3df; + --vscode-activityBar-background: #000000; + --vscode-activityBar-border: #6fc3df; + --vscode-activityBar-foreground: #ffffff; + --vscode-activityBar-inactiveForeground: #ffffff; + --vscode-activityBarBadge-background: #000000; + --vscode-activityBarBadge-foreground: #ffffff; + --vscode-activityBarTop-activeBorder: #6fc3df; + --vscode-activityBarTop-dropBorder: #ffffff; + --vscode-activityBarTop-foreground: #ffffff; + --vscode-activityBarTop-inactiveForeground: #ffffff; + --vscode-badge-background: #000000; + --vscode-badge-foreground: #ffffff; + --vscode-banner-iconForeground: #3794ff; + --vscode-breadcrumb-activeSelectionForeground: #ffffff; + --vscode-breadcrumb-background: #000000; + --vscode-breadcrumb-focusForeground: #ffffff; + --vscode-breadcrumb-foreground: rgba(255, 255, 255, 0.8); + --vscode-breadcrumbPicker-background: #0c141f; + --vscode-button-border: #6fc3df; + --vscode-button-foreground: #ffffff; + --vscode-button-secondaryForeground: #ffffff; + --vscode-button-separator: rgba(255, 255, 255, 0.4); + --vscode-chart-axis: #6fc3df; + --vscode-chart-guide: #6fc3df; + --vscode-chart-line: #236b8e; + --vscode-charts-blue: #3794ff; + --vscode-charts-foreground: #ffffff; + --vscode-charts-green: #89d185; + --vscode-charts-lines: rgba(255, 255, 255, 0.5); + --vscode-charts-orange: #ab5a00; + --vscode-charts-purple: #b180d7; + --vscode-charts-red: #f48771; + --vscode-charts-yellow: #ffd370; + --vscode-chat-avatarBackground: #000000; + --vscode-chat-avatarForeground: #ffffff; + --vscode-chat-editedFileForeground: #e2c08d; + --vscode-chat-requestBackground: #0c141f; + --vscode-chat-requestBorder: #6fc3df; + --vscode-chat-slashCommandBackground: #ffffff; + --vscode-chat-slashCommandForeground: #000000; + --vscode-checkbox-background: #000000; + --vscode-checkbox-border: #6fc3df; + --vscode-checkbox-disabled.background: #545454; + --vscode-checkbox-disabled.foreground: #aaaaaa; + --vscode-checkbox-foreground: #ffffff; + --vscode-checkbox-selectBackground: #0c141f; + --vscode-checkbox-selectBorder: #ffffff; + --vscode-commandCenter-activeBorder: #ffffff; + --vscode-commandCenter-activeForeground: #ffffff; + --vscode-commandCenter-border: #6fc3df; + --vscode-commandCenter-debuggingBackground: rgba(186, 89, 44, 0.26); + --vscode-commandCenter-foreground: #ffffff; + --vscode-commentsView-resolvedIcon: #6fc3df; + --vscode-commentsView-unresolvedIcon: #6fc3df; + --vscode-contrastActiveBorder: #f38518; + --vscode-contrastBorder: #6fc3df; + --vscode-debugConsole-errorForeground: #f48771; + --vscode-debugConsole-infoForeground: #ffffff; + --vscode-debugConsole-sourceForeground: #ffffff; + --vscode-debugConsole-warningForeground: #008000; + --vscode-debugConsoleInputIcon-foreground: #ffffff; + --vscode-debugExceptionWidget-background: #420b0d; + --vscode-debugExceptionWidget-border: #a31515; + --vscode-debugIcon-breakpointCurrentStackframeForeground: #ffcc00; + --vscode-debugIcon-breakpointDisabledForeground: #848484; + --vscode-debugIcon-breakpointForeground: #e51400; + --vscode-debugIcon-breakpointStackframeForeground: #89d185; + --vscode-debugIcon-breakpointUnverifiedForeground: #848484; + --vscode-debugIcon-continueForeground: #75beff; + --vscode-debugIcon-disconnectForeground: #f48771; + --vscode-debugIcon-pauseForeground: #75beff; + --vscode-debugIcon-restartForeground: #89d185; + --vscode-debugIcon-startForeground: #89d185; + --vscode-debugIcon-stepBackForeground: #75beff; + --vscode-debugIcon-stepIntoForeground: #75beff; + --vscode-debugIcon-stepOutForeground: #75beff; + --vscode-debugIcon-stepOverForeground: #75beff; + --vscode-debugIcon-stopForeground: #f48771; + --vscode-debugTokenExpression-boolean: #75bdfe; + --vscode-debugTokenExpression-error: #f48771; + --vscode-debugTokenExpression-name: #ffffff; + --vscode-debugTokenExpression-number: #89d185; + --vscode-debugTokenExpression-string: #f48771; + --vscode-debugTokenExpression-type: #ffffff; + --vscode-debugTokenExpression-value: #ffffff; + --vscode-debugToolBar-background: #000000; + --vscode-debugView-exceptionLabelBackground: #6c2022; + --vscode-debugView-exceptionLabelForeground: #ffffff; + --vscode-debugView-stateLabelBackground: rgba(136, 136, 136, 0.27); + --vscode-debugView-stateLabelForeground: #ffffff; + --vscode-debugView-valueChangedHighlight: #569cd6; + --vscode-descriptionForeground: rgba(255, 255, 255, 0.7); + --vscode-diffEditor-border: #6fc3df; + --vscode-diffEditor-insertedTextBorder: #33ff2e; + --vscode-diffEditor-move.border: rgba(139, 139, 139, 0.61); + --vscode-diffEditor-moveActive.border: #ffa500; + --vscode-diffEditor-removedTextBorder: #ff008f; + --vscode-diffEditor-unchangedRegionBackground: #000000; + --vscode-diffEditor-unchangedRegionForeground: #ffffff; + --vscode-diffEditor-unchangedRegionShadow: #000000; + --vscode-disabledForeground: #a5a5a5; + --vscode-dropdown-background: #000000; + --vscode-dropdown-border: #6fc3df; + --vscode-dropdown-foreground: #ffffff; + --vscode-dropdown-listBackground: #000000; + --vscode-editor-background: #000000; + --vscode-editor-compositionBorder: #ffffff; + --vscode-editor-findMatchBorder: #f38518; + --vscode-editor-findMatchHighlightBorder: #f38518; + --vscode-editor-findRangeHighlightBorder: rgba(243, 133, 24, 0.4); + --vscode-editor-focusedStackFrameHighlightBackground: rgba(122, 189, 122, 0.3); + --vscode-editor-font-size: 14px; + --vscode-editor-font-weight: normal; + --vscode-editor-foreground: #ffffff; + --vscode-editor-hoverHighlightBackground: rgba(173, 214, 255, 0.15); + --vscode-editor-inactiveSelectionBackground: rgba(255, 255, 255, 0.7); + --vscode-editor-inlineValuesBackground: rgba(255, 200, 0, 0.2); + --vscode-editor-inlineValuesForeground: rgba(255, 255, 255, 0.5); + --vscode-editor-lineHighlightBorder: #f38518; + --vscode-editor-linkedEditingBackground: rgba(255, 0, 0, 0.3); + --vscode-editor-rangeHighlightBorder: #f38518; + --vscode-editor-selectionBackground: #ffffff; + --vscode-editor-selectionForeground: #000000; + --vscode-editor-selectionHighlightBorder: #f38518; + --vscode-editor-snippetFinalTabstopHighlightBorder: #525252; + --vscode-editor-snippetTabstopHighlightBackground: rgba(124, 124, 124, 0.3); + --vscode-editor-stackFrameHighlightBackground: rgba(255, 255, 0, 0.2); + --vscode-editor-symbolHighlightBorder: #f38518; + --vscode-editor-wordHighlightBorder: #f38518; + --vscode-editor-wordHighlightStrongBorder: #f38518; + --vscode-editor-wordHighlightTextBorder: #f38518; + --vscode-editorActionList-background: #0c141f; + --vscode-editorActionList-foreground: #ffffff; + --vscode-editorActiveLineNumber-foreground: #f38518; + --vscode-editorBracketHighlight-foreground1: #ffd700; + --vscode-editorBracketHighlight-foreground2: #da70d6; + --vscode-editorBracketHighlight-foreground3: #87cefa; + --vscode-editorBracketHighlight-foreground4: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-foreground5: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-foreground6: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-unexpectedBracket.foreground: #ff3232; + --vscode-editorBracketMatch-background: rgba(0, 100, 0, 0.1); + --vscode-editorBracketMatch-border: #6fc3df; + --vscode-editorBracketPairGuide-activeBackground1: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground2: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground3: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground4: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground5: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground6: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background1: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background2: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background3: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background4: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background5: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background6: rgba(0, 0, 0, 0); + --vscode-editorCodeLens-foreground: #999999; + --vscode-editorCommentsWidget-rangeActiveBackground: rgba(111, 195, 223, 0.1); + --vscode-editorCommentsWidget-rangeBackground: rgba(111, 195, 223, 0.1); + --vscode-editorCommentsWidget-replyInputBackground: #000000; + --vscode-editorCommentsWidget-resolvedBorder: #6fc3df; + --vscode-editorCommentsWidget-unresolvedBorder: #6fc3df; + --vscode-editorCursor-foreground: #ffffff; + --vscode-editorError-border: rgba(228, 119, 119, 0.8); + --vscode-editorError-foreground: #f48771; + --vscode-editorGhostText-border: rgba(255, 255, 255, 0.8); + --vscode-editorGroup-border: #6fc3df; + --vscode-editorGroup-dropIntoPromptBackground: #0c141f; + --vscode-editorGroup-dropIntoPromptBorder: #6fc3df; + --vscode-editorGroup-dropIntoPromptForeground: #ffffff; + --vscode-editorGroup-focusedEmptyBorder: #f38518; + --vscode-editorGroupHeader-border: #6fc3df; + --vscode-editorGroupHeader-noTabsBackground: #000000; + --vscode-editorGutter-addedBackground: #487e02; + --vscode-editorGutter-addedSecondaryBackground: #487e02; + --vscode-editorGutter-background: #000000; + --vscode-editorGutter-commentGlyphForeground: #000000; + --vscode-editorGutter-commentRangeForeground: #ffffff; + --vscode-editorGutter-commentUnresolvedGlyphForeground: #000000; + --vscode-editorGutter-deletedBackground: #f48771; + --vscode-editorGutter-deletedSecondaryBackground: #f48771; + --vscode-editorGutter-foldingControlForeground: #ffffff; + --vscode-editorGutter-itemBackground: #ffffff; + --vscode-editorGutter-itemGlyphForeground: #000000; + --vscode-editorGutter-modifiedBackground: #1b81a8; + --vscode-editorGutter-modifiedSecondaryBackground: #1b81a8; + --vscode-editorHint-border: rgba(238, 238, 238, 0.8); + --vscode-editorHoverWidget-background: #0c141f; + --vscode-editorHoverWidget-border: #6fc3df; + --vscode-editorHoverWidget-foreground: #ffffff; + --vscode-editorHoverWidget-highlightForeground: #f38518; + --vscode-editorHoverWidget-statusBarBackground: #0c141f; + --vscode-editorIndentGuide-activeBackground: #7c7c7c; + --vscode-editorIndentGuide-activeBackground1: #ffffff; + --vscode-editorIndentGuide-activeBackground2: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground3: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground4: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground5: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground6: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background: #7c7c7c; + --vscode-editorIndentGuide-background1: #ffffff; + --vscode-editorIndentGuide-background2: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background3: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background4: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background5: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background6: rgba(0, 0, 0, 0); + --vscode-editorInfo-border: rgba(55, 148, 255, 0.8); + --vscode-editorInfo-foreground: #3794ff; + --vscode-editorInlayHint-background: rgba(255, 255, 255, 0.1); + --vscode-editorInlayHint-foreground: #ffffff; + --vscode-editorInlayHint-parameterBackground: rgba(255, 255, 255, 0.1); + --vscode-editorInlayHint-parameterForeground: #ffffff; + --vscode-editorInlayHint-typeBackground: rgba(255, 255, 255, 0.1); + --vscode-editorInlayHint-typeForeground: #ffffff; + --vscode-editorLightBulb-foreground: #ffcc00; + --vscode-editorLightBulbAi-foreground: #ffcc00; + --vscode-editorLightBulbAutoFix-foreground: #75beff; + --vscode-editorLineNumber-activeForeground: #f38518; + --vscode-editorLineNumber-foreground: #ffffff; + --vscode-editorLink-activeForeground: #00ffff; + --vscode-editorMarkerNavigation-background: #000000; + --vscode-editorMarkerNavigationError-background: #6fc3df; + --vscode-editorMarkerNavigationInfo-background: #6fc3df; + --vscode-editorMarkerNavigationWarning-background: #6fc3df; + --vscode-editorMarkerNavigationWarning-headerBackground: #0c141f; + --vscode-editorMultiCursor-primary.foreground: #ffffff; + --vscode-editorMultiCursor-secondary.foreground: #ffffff; + --vscode-editorOverviewRuler-addedForeground: rgba(72, 126, 2, 0.6); + --vscode-editorOverviewRuler-border: rgba(127, 127, 127, 0.3); + --vscode-editorOverviewRuler-bracketMatchForeground: #a0a0a0; + --vscode-editorOverviewRuler-commentForeground: #ffffff; + --vscode-editorOverviewRuler-commentUnresolvedForeground: #ffffff; + --vscode-editorOverviewRuler-commonContentForeground: #c3df6f; + --vscode-editorOverviewRuler-currentContentForeground: #c3df6f; + --vscode-editorOverviewRuler-deletedForeground: rgba(244, 135, 113, 0.6); + --vscode-editorOverviewRuler-errorForeground: #ff3232; + --vscode-editorOverviewRuler-findMatchForeground: #ab5a00; + --vscode-editorOverviewRuler-incomingContentForeground: #c3df6f; + --vscode-editorOverviewRuler-infoForeground: rgba(55, 148, 255, 0.8); + --vscode-editorOverviewRuler-modifiedForeground: rgba(27, 129, 168, 0.6); + --vscode-editorOverviewRuler-rangeHighlightForeground: rgba(0, 122, 204, 0.6); + --vscode-editorOverviewRuler-selectionHighlightForeground: rgba(160, 160, 160, 0.8); + --vscode-editorOverviewRuler-warningForeground: rgba(255, 204, 0, 0.8); + --vscode-editorOverviewRuler-wordHighlightForeground: rgba(160, 160, 160, 0.8); + --vscode-editorOverviewRuler-wordHighlightStrongForeground: rgba(192, 160, 192, 0.8); + --vscode-editorOverviewRuler-wordHighlightTextForeground: rgba(160, 160, 160, 0.8); + --vscode-editorPane-background: #000000; + --vscode-editorRuler-foreground: #ffffff; + --vscode-editorStickyScroll-background: #000000; + --vscode-editorStickyScroll-border: #6fc3df; + --vscode-editorSuggestWidget-background: #0c141f; + --vscode-editorSuggestWidget-border: #6fc3df; + --vscode-editorSuggestWidget-focusHighlightForeground: #f38518; + --vscode-editorSuggestWidget-foreground: #ffffff; + --vscode-editorSuggestWidget-highlightForeground: #f38518; + --vscode-editorSuggestWidgetStatus-foreground: rgba(255, 255, 255, 0.5); + --vscode-editorUnicodeHighlight-border: #ffd370; + --vscode-editorUnnecessaryCode-border: rgba(255, 255, 255, 0.8); + --vscode-editorWarning-border: rgba(255, 204, 0, 0.8); + --vscode-editorWarning-foreground: #ffd370; + --vscode-editorWatermark-foreground: #ffffff; + --vscode-editorWhitespace-foreground: #7c7c7c; + --vscode-editorWidget-background: #0c141f; + --vscode-editorWidget-border: #6fc3df; + --vscode-editorWidget-foreground: #ffffff; + --vscode-errorForeground: #f48771; + --vscode-extensionBadge-remoteBackground: #000000; + --vscode-extensionBadge-remoteForeground: #ffffff; + --vscode-extensionButton-separator: rgba(255, 255, 255, 0.4); + --vscode-extensionIcon-preReleaseForeground: #1d9271; + --vscode-extensionIcon-privateForeground: rgba(255, 255, 255, 0.38); + --vscode-extensionIcon-starForeground: #ff8e00; + --vscode-extensionIcon-verifiedForeground: #21a6ff; + --vscode-focusBorder: #f38518; + --vscode-font-size: 13px; + --vscode-font-weight: normal; + --vscode-foreground: #ffffff; + --vscode-gauge-background: #6fc3df; + --vscode-gauge-border: #6fc3df; + --vscode-gauge-errorBackground: #6fc3df; + --vscode-gauge-errorForeground: #ffffff; + --vscode-gauge-foreground: #ffffff; + --vscode-gauge-warningBackground: #6fc3df; + --vscode-gauge-warningForeground: #ffffff; + --vscode-git-blame.editorDecorationForeground: #ffffff; + --vscode-gitDecoration-addedResourceForeground: #a1e3ad; + --vscode-gitDecoration-conflictingResourceForeground: #c74e39; + --vscode-gitDecoration-deletedResourceForeground: #c74e39; + --vscode-gitDecoration-ignoredResourceForeground: #a7a8a9; + --vscode-gitDecoration-modifiedResourceForeground: #e2c08d; + --vscode-gitDecoration-renamedResourceForeground: #73c991; + --vscode-gitDecoration-stageDeletedResourceForeground: #c74e39; + --vscode-gitDecoration-stageModifiedResourceForeground: #e2c08d; + --vscode-gitDecoration-submoduleResourceForeground: #8db9e2; + --vscode-gitDecoration-untrackedResourceForeground: #73c991; + --vscode-icon-foreground: #ffffff; + --vscode-inlineChat-background: #0c141f; + --vscode-inlineChat-border: #6fc3df; + --vscode-inlineChat-foreground: #ffffff; + --vscode-inlineChatInput-background: #000000; + --vscode-inlineChatInput-border: #6fc3df; + --vscode-inlineChatInput-focusBorder: #f38518; + --vscode-inlineChatInput-placeholderForeground: rgba(255, 255, 255, 0.7); + --vscode-inlineEdit-gutterIndicator.primaryForeground: #ffffff; + --vscode-inlineEdit-gutterIndicator.secondaryForeground: #ffffff; + --vscode-inlineEdit-gutterIndicator.successfulForeground: #ffffff; + --vscode-input-background: #000000; + --vscode-input-border: #6fc3df; + --vscode-input-foreground: #ffffff; + --vscode-input-placeholderForeground: rgba(255, 255, 255, 0.7); + --vscode-inputOption-activeBackground: rgba(0, 0, 0, 0); + --vscode-inputOption-activeBorder: #6fc3df; + --vscode-inputOption-activeForeground: #ffffff; + --vscode-inputValidation-errorBackground: #000000; + --vscode-inputValidation-errorBorder: #6fc3df; + --vscode-inputValidation-infoBackground: #000000; + --vscode-inputValidation-infoBorder: #6fc3df; + --vscode-inputValidation-warningBackground: #000000; + --vscode-inputValidation-warningBorder: #6fc3df; + --vscode-interactive-activeCodeBorder: #6fc3df; + --vscode-interactive-inactiveCodeBorder: #6fc3df; + --vscode-keybindingLabel-background: rgba(0, 0, 0, 0); + --vscode-keybindingLabel-border: #6fc3df; + --vscode-keybindingLabel-bottomBorder: #6fc3df; + --vscode-keybindingLabel-foreground: #ffffff; + --vscode-list-deemphasizedForeground: #a7a8a9; + --vscode-list-filterMatchBorder: #6fc3df; + --vscode-list-focusHighlightForeground: #f38518; + --vscode-list-focusOutline: #f38518; + --vscode-list-highlightForeground: #f38518; + --vscode-list-hoverBackground: rgba(255, 255, 255, 0.1); + --vscode-list-invalidItemForeground: #b89500; + --vscode-listFilterWidget-background: #0c141f; + --vscode-listFilterWidget-noMatchesOutline: #6fc3df; + --vscode-listFilterWidget-outline: #f38518; + --vscode-menu-background: #000000; + --vscode-menu-border: #6fc3df; + --vscode-menu-foreground: #ffffff; + --vscode-menu-selectionBorder: #f38518; + --vscode-menu-separatorBackground: #6fc3df; + --vscode-menubar-selectionBorder: #f38518; + --vscode-menubar-selectionForeground: #ffffff; + --vscode-merge-border: #c3df6f; + --vscode-mergeEditor-change.background: rgba(155, 185, 85, 0.2); + --vscode-mergeEditor-change.word.background: rgba(156, 204, 44, 0.2); + --vscode-mergeEditor-changeBase.background: #4b1818; + --vscode-mergeEditor-changeBase.word.background: #6f1313; + --vscode-mergeEditor-conflict.handled.minimapOverViewRuler: rgba(173, 172, 168, 0.93); + --vscode-mergeEditor-conflict.handledFocused.border: rgba(193, 193, 193, 0.8); + --vscode-mergeEditor-conflict.handledUnfocused.border: rgba(134, 134, 134, 0.29); + --vscode-mergeEditor-conflict.unhandled.minimapOverViewRuler: #fcba03; + --vscode-mergeEditor-conflict.unhandledFocused.border: #ffa600; + --vscode-mergeEditor-conflict.unhandledUnfocused.border: rgba(255, 166, 0, 0.48); + --vscode-mergeEditor-conflictingLines.background: rgba(255, 234, 0, 0.28); + --vscode-minimap-chatEditHighlight: rgba(0, 0, 0, 0.6); + --vscode-minimap-errorHighlight: #ff3232; + --vscode-minimap-findMatchHighlight: #ab5a00; + --vscode-minimap-foregroundOpacity: #000000; + --vscode-minimap-infoHighlight: rgba(55, 148, 255, 0.8); + --vscode-minimap-selectionHighlight: #ffffff; + --vscode-minimap-selectionOccurrenceHighlight: #ffffff; + --vscode-minimap-warningHighlight: rgba(255, 204, 0, 0.8); + --vscode-minimapGutter-addedBackground: #487e02; + --vscode-minimapGutter-deletedBackground: #f48771; + --vscode-minimapGutter-modifiedBackground: #1b81a8; + --vscode-minimapSlider-activeBackground: rgba(111, 195, 223, 0.5); + --vscode-minimapSlider-background: rgba(111, 195, 223, 0.3); + --vscode-minimapSlider-hoverBackground: rgba(111, 195, 223, 0.4); + --vscode-multiDiffEditor-background: #000000; + --vscode-multiDiffEditor-border: #6fc3df; + --vscode-notebook-cellBorderColor: #6fc3df; + --vscode-notebook-cellInsertionIndicator: #f38518; + --vscode-notebook-cellStatusBarItemHoverBackground: rgba(255, 255, 255, 0.15); + --vscode-notebook-cellToolbarSeparator: #6fc3df; + --vscode-notebook-focusedCellBorder: #f38518; + --vscode-notebook-focusedEditorBorder: #f38518; + --vscode-notebook-inactiveFocusedCellBorder: #6fc3df; + --vscode-notebook-inactiveSelectedCellBorder: #f38518; + --vscode-notebook-selectedCellBorder: #6fc3df; + --vscode-notebookEditorOverviewRuler-runningCellForeground: #89d185; + --vscode-notebookScrollbarSlider-activeBackground: #6fc3df; + --vscode-notebookScrollbarSlider-background: rgba(111, 195, 223, 0.6); + --vscode-notebookScrollbarSlider-hoverBackground: rgba(111, 195, 223, 0.8); + --vscode-notebookStatusErrorIcon-foreground: #f48771; + --vscode-notebookStatusRunningIcon-foreground: #ffffff; + --vscode-notebookStatusSuccessIcon-foreground: #89d185; + --vscode-notificationCenter-border: #6fc3df; + --vscode-notificationCenterHeader-background: #0c141f; + --vscode-notificationLink-foreground: #21a6ff; + --vscode-notificationToast-border: #6fc3df; + --vscode-notifications-background: #0c141f; + --vscode-notifications-border: #0c141f; + --vscode-notifications-foreground: #ffffff; + --vscode-notificationsErrorIcon-foreground: #f48771; + --vscode-notificationsInfoIcon-foreground: #3794ff; + --vscode-notificationsWarningIcon-foreground: #ffd370; + --vscode-panel-background: #000000; + --vscode-panel-border: #6fc3df; + --vscode-panel-dropBorder: #ffffff; + --vscode-panelInput-border: #6fc3df; + --vscode-panelSection-border: #6fc3df; + --vscode-panelSectionHeader-border: #6fc3df; + --vscode-panelStickyScroll-background: #000000; + --vscode-panelTitle-activeBorder: #6fc3df; + --vscode-panelTitle-activeForeground: #ffffff; + --vscode-panelTitle-border: #6fc3df; + --vscode-panelTitle-inactiveForeground: #ffffff; + --vscode-panelTitleBadge-background: #000000; + --vscode-panelTitleBadge-foreground: #ffffff; + --vscode-peekView-border: #6fc3df; + --vscode-peekViewEditor-background: #000000; + --vscode-peekViewEditor-matchHighlightBorder: #f38518; + --vscode-peekViewEditorGutter-background: #000000; + --vscode-peekViewEditorStickyScroll-background: #000000; + --vscode-peekViewResult-background: #000000; + --vscode-peekViewResult-fileForeground: #ffffff; + --vscode-peekViewResult-lineForeground: #ffffff; + --vscode-peekViewResult-selectionForeground: #ffffff; + --vscode-peekViewTitle-background: #000000; + --vscode-peekViewTitleDescription-foreground: rgba(255, 255, 255, 0.6); + --vscode-peekViewTitleLabel-foreground: #ffffff; + --vscode-pickerGroup-border: #ffffff; + --vscode-pickerGroup-foreground: #ffffff; + --vscode-ports-iconRunningProcessForeground: #ffffff; + --vscode-problemsErrorIcon-foreground: #f48771; + --vscode-problemsInfoIcon-foreground: #3794ff; + --vscode-problemsWarningIcon-foreground: #ffd370; + --vscode-profileBadge-background: #ffffff; + --vscode-profileBadge-foreground: #000000; + --vscode-profiles-sashBorder: #6fc3df; + --vscode-progressBar-background: #6fc3df; + --vscode-prompt-frontMatter.background: #6fc3df; + --vscode-prompt-frontMatter.inactiveBackground: #6fc3df; + --vscode-quickInput-background: #0c141f; + --vscode-quickInput-foreground: #ffffff; + --vscode-quickInputTitle-background: #000000; + --vscode-radio-activeBackground: rgba(0, 0, 0, 0); + --vscode-radio-activeBorder: #6fc3df; + --vscode-radio-activeForeground: #ffffff; + --vscode-radio-inactiveBorder: rgba(255, 255, 255, 0.4); + --vscode-sash-hoverBorder: #f38518; + --vscode-scmGraph-foreground1: #ffb000; + --vscode-scmGraph-foreground2: #dc267f; + --vscode-scmGraph-foreground3: #994f00; + --vscode-scmGraph-foreground4: #40b0a6; + --vscode-scmGraph-foreground5: #b66dff; + --vscode-scmGraph-historyItemBaseRefColor: #ea5c00; + --vscode-scmGraph-historyItemHoverAdditionsForeground: #a1e3ad; + --vscode-scmGraph-historyItemHoverDefaultLabelBackground: #000000; + --vscode-scmGraph-historyItemHoverDefaultLabelForeground: #ffffff; + --vscode-scmGraph-historyItemHoverDeletionsForeground: #c74e39; + --vscode-scmGraph-historyItemHoverLabelForeground: #ffffff; + --vscode-scmGraph-historyItemRefColor: #3794ff; + --vscode-scmGraph-historyItemRemoteRefColor: #b180d7; + --vscode-scrollbarSlider-activeBackground: #6fc3df; + --vscode-scrollbarSlider-background: rgba(111, 195, 223, 0.6); + --vscode-scrollbarSlider-hoverBackground: rgba(111, 195, 223, 0.8); + --vscode-search-resultsInfoForeground: #ffffff; + --vscode-searchEditor-findMatchBorder: #f38518; + --vscode-searchEditor-textInputBorder: #6fc3df; + --vscode-selection-background: #008000; + --vscode-settings-checkboxBackground: #000000; + --vscode-settings-checkboxBorder: #6fc3df; + --vscode-settings-checkboxForeground: #ffffff; + --vscode-settings-dropdownBackground: #000000; + --vscode-settings-dropdownBorder: #6fc3df; + --vscode-settings-dropdownForeground: #ffffff; + --vscode-settings-dropdownListBorder: #6fc3df; + --vscode-settings-focusedRowBorder: #f38518; + --vscode-settings-headerBorder: #6fc3df; + --vscode-settings-headerForeground: #ffffff; + --vscode-settings-modifiedItemIndicator: #00497a; + --vscode-settings-numberInputBackground: #000000; + --vscode-settings-numberInputBorder: #6fc3df; + --vscode-settings-numberInputForeground: #ffffff; + --vscode-settings-sashBorder: #6fc3df; + --vscode-settings-settingsHeaderHoverForeground: rgba(255, 255, 255, 0.7); + --vscode-settings-textInputBackground: #000000; + --vscode-settings-textInputBorder: #6fc3df; + --vscode-settings-textInputForeground: #ffffff; + --vscode-sideBar-background: #000000; + --vscode-sideBar-border: #6fc3df; + --vscode-sideBarActivityBarTop-border: #6fc3df; + --vscode-sideBarSectionHeader-border: #6fc3df; + --vscode-sideBarStickyScroll-background: #000000; + --vscode-sideBarTitle-background: #000000; + --vscode-sideBarTitle-border: #6fc3df; + --vscode-sideBarTitle-foreground: #ffffff; + --vscode-sideBySideEditor-horizontalBorder: #6fc3df; + --vscode-sideBySideEditor-verticalBorder: #6fc3df; + --vscode-simpleFindWidget-sashBorder: #6fc3df; + --vscode-statusBar-border: #6fc3df; + --vscode-statusBar-debuggingBackground: #ba592c; + --vscode-statusBar-debuggingBorder: #6fc3df; + --vscode-statusBar-debuggingForeground: #ffffff; + --vscode-statusBar-foreground: #ffffff; + --vscode-statusBar-noFolderBorder: #6fc3df; + --vscode-statusBar-noFolderForeground: #ffffff; + --vscode-statusBarItem-activeBackground: rgba(255, 255, 255, 0.18); + --vscode-statusBarItem-compactHoverBackground: rgba(255, 255, 255, 0.2); + --vscode-statusBarItem-errorForeground: #ffffff; + --vscode-statusBarItem-errorHoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-errorHoverForeground: #ffffff; + --vscode-statusBarItem-hoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-hoverForeground: #ffffff; + --vscode-statusBarItem-offlineBackground: #6c1717; + --vscode-statusBarItem-offlineForeground: #ffffff; + --vscode-statusBarItem-offlineHoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-offlineHoverForeground: #ffffff; + --vscode-statusBarItem-prominentBackground: rgba(0, 0, 0, 0.5); + --vscode-statusBarItem-prominentForeground: #ffffff; + --vscode-statusBarItem-prominentHoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-prominentHoverForeground: #ffffff; + --vscode-statusBarItem-remoteBackground: rgba(0, 0, 0, 0); + --vscode-statusBarItem-remoteForeground: #ffffff; + --vscode-statusBarItem-remoteHoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-remoteHoverForeground: #ffffff; + --vscode-statusBarItem-warningForeground: #ffffff; + --vscode-statusBarItem-warningHoverBackground: rgba(255, 255, 255, 0.12); + --vscode-statusBarItem-warningHoverForeground: #ffffff; + --vscode-symbolIcon-arrayForeground: #ffffff; + --vscode-symbolIcon-booleanForeground: #ffffff; + --vscode-symbolIcon-classForeground: #ee9d28; + --vscode-symbolIcon-colorForeground: #ffffff; + --vscode-symbolIcon-constantForeground: #ffffff; + --vscode-symbolIcon-constructorForeground: #b180d7; + --vscode-symbolIcon-enumeratorForeground: #ee9d28; + --vscode-symbolIcon-enumeratorMemberForeground: #75beff; + --vscode-symbolIcon-eventForeground: #ee9d28; + --vscode-symbolIcon-fieldForeground: #75beff; + --vscode-symbolIcon-fileForeground: #ffffff; + --vscode-symbolIcon-folderForeground: #ffffff; + --vscode-symbolIcon-functionForeground: #b180d7; + --vscode-symbolIcon-interfaceForeground: #75beff; + --vscode-symbolIcon-keyForeground: #ffffff; + --vscode-symbolIcon-keywordForeground: #ffffff; + --vscode-symbolIcon-methodForeground: #b180d7; + --vscode-symbolIcon-moduleForeground: #ffffff; + --vscode-symbolIcon-namespaceForeground: #ffffff; + --vscode-symbolIcon-nullForeground: #ffffff; + --vscode-symbolIcon-numberForeground: #ffffff; + --vscode-symbolIcon-objectForeground: #ffffff; + --vscode-symbolIcon-operatorForeground: #ffffff; + --vscode-symbolIcon-packageForeground: #ffffff; + --vscode-symbolIcon-propertyForeground: #ffffff; + --vscode-symbolIcon-referenceForeground: #ffffff; + --vscode-symbolIcon-snippetForeground: #ffffff; + --vscode-symbolIcon-stringForeground: #ffffff; + --vscode-symbolIcon-structForeground: #ffffff; + --vscode-symbolIcon-textForeground: #ffffff; + --vscode-symbolIcon-typeParameterForeground: #ffffff; + --vscode-symbolIcon-unitForeground: #ffffff; + --vscode-symbolIcon-variableForeground: #75beff; + --vscode-tab-activeBackground: #000000; + --vscode-tab-activeForeground: #ffffff; + --vscode-tab-border: #6fc3df; + --vscode-tab-dragAndDropBorder: #f38518; + --vscode-tab-inactiveForeground: #ffffff; + --vscode-tab-inactiveModifiedBorder: #ffffff; + --vscode-tab-lastPinnedBorder: #6fc3df; + --vscode-tab-selectedBackground: #000000; + --vscode-tab-selectedForeground: #ffffff; + --vscode-tab-unfocusedActiveBackground: #000000; + --vscode-tab-unfocusedActiveForeground: #ffffff; + --vscode-tab-unfocusedActiveModifiedBorder: #ffffff; + --vscode-tab-unfocusedInactiveForeground: #ffffff; + --vscode-tab-unfocusedInactiveModifiedBorder: #ffffff; + --vscode-terminal-ansiBlack: #000000; + --vscode-terminal-ansiBlue: #0000ee; + --vscode-terminal-ansiBrightBlack: #7f7f7f; + --vscode-terminal-ansiBrightBlue: #5c5cff; + --vscode-terminal-ansiBrightCyan: #00ffff; + --vscode-terminal-ansiBrightGreen: #00ff00; + --vscode-terminal-ansiBrightMagenta: #ff00ff; + --vscode-terminal-ansiBrightRed: #ff0000; + --vscode-terminal-ansiBrightWhite: #ffffff; + --vscode-terminal-ansiBrightYellow: #ffff00; + --vscode-terminal-ansiCyan: #00cdcd; + --vscode-terminal-ansiGreen: #00cd00; + --vscode-terminal-ansiMagenta: #cd00cd; + --vscode-terminal-ansiRed: #cd0000; + --vscode-terminal-ansiWhite: #e5e5e5; + --vscode-terminal-ansiYellow: #cdcd00; + --vscode-terminal-border: #6fc3df; + --vscode-terminal-findMatchBorder: #f38518; + --vscode-terminal-findMatchHighlightBorder: #f38518; + --vscode-terminal-foreground: #ffffff; + --vscode-terminal-hoverHighlightBackground: rgba(173, 214, 255, 0.07); + --vscode-terminal-inactiveSelectionBackground: rgba(255, 255, 255, 0.7); + --vscode-terminal-selectionBackground: #ffffff; + --vscode-terminal-selectionForeground: #000000; + --vscode-terminalCommandDecoration-defaultBackground: rgba(255, 255, 255, 0.5); + --vscode-terminalCommandDecoration-errorBackground: #f14c4c; + --vscode-terminalCommandDecoration-successBackground: #1b81a8; + --vscode-terminalCommandGuide-foreground: #6fc3df; + --vscode-terminalOverviewRuler-border: rgba(127, 127, 127, 0.3); + --vscode-terminalOverviewRuler-cursorForeground: rgba(160, 160, 160, 0.8); + --vscode-terminalOverviewRuler-findMatchForeground: #f38518; + --vscode-terminalStickyScroll-border: #6fc3df; + --vscode-terminalStickyScrollHover-background: #e48b39; + --vscode-terminalSymbolIcon-aliasForeground: #b180d7; + --vscode-terminalSymbolIcon-argumentForeground: #75beff; + --vscode-terminalSymbolIcon-fileForeground: #ffffff; + --vscode-terminalSymbolIcon-flagForeground: #ee9d28; + --vscode-terminalSymbolIcon-folderForeground: #ffffff; + --vscode-terminalSymbolIcon-methodForeground: #b180d7; + --vscode-terminalSymbolIcon-optionForeground: #ee9d28; + --vscode-terminalSymbolIcon-optionValueForeground: #75beff; + --vscode-testing-coverCountBadgeBackground: #000000; + --vscode-testing-coverCountBadgeForeground: #ffffff; + --vscode-testing-coveredBorder: #6fc3df; + --vscode-testing-coveredGutterBackground: #89d185; + --vscode-testing-iconErrored: #f14c4c; + --vscode-testing-iconErrored.retired: rgba(241, 76, 76, 0.7); + --vscode-testing-iconFailed: #f14c4c; + --vscode-testing-iconFailed.retired: rgba(241, 76, 76, 0.7); + --vscode-testing-iconPassed: #73c991; + --vscode-testing-iconPassed.retired: rgba(115, 201, 145, 0.7); + --vscode-testing-iconQueued: #cca700; + --vscode-testing-iconQueued.retired: rgba(204, 167, 0, 0.7); + --vscode-testing-iconSkipped: #848484; + --vscode-testing-iconSkipped.retired: rgba(132, 132, 132, 0.7); + --vscode-testing-iconUnset: #848484; + --vscode-testing-iconUnset.retired: rgba(132, 132, 132, 0.7); + --vscode-testing-message.info.decorationForeground: rgba(255, 255, 255, 0.5); + --vscode-testing-messagePeekBorder: #6fc3df; + --vscode-testing-peekBorder: #6fc3df; + --vscode-testing-runAction: #73c991; + --vscode-testing-uncoveredBorder: #6fc3df; + --vscode-testing-uncoveredGutterBackground: #f48771; + --vscode-textBlockQuote-border: #ffffff; + --vscode-textCodeBlock-background: #000000; + --vscode-textLink-activeForeground: #21a6ff; + --vscode-textLink-foreground: #21a6ff; + --vscode-textPreformat-background: #ffffff; + --vscode-textPreformat-foreground: #000000; + --vscode-textSeparator-foreground: #000000; + --vscode-titleBar-activeBackground: #000000; + --vscode-titleBar-activeForeground: #ffffff; + --vscode-titleBar-border: #6fc3df; + --vscode-toolbar-hoverOutline: #f38518; + --vscode-tree-inactiveIndentGuidesStroke: rgba(169, 169, 169, 0.4); + --vscode-tree-indentGuidesStroke: #a9a9a9; + --vscode-welcomePage-progress.background: #000000; + --vscode-welcomePage-progress.foreground: #21a6ff; + --vscode-welcomePage-tileBackground: #000000; + --vscode-welcomePage-tileBorder: #6fc3df; + --vscode-widget-border: #6fc3df; + --vscode-window-activeBorder: #6fc3df; + --vscode-window-inactiveBorder: #6fc3df; +} diff --git a/webview-ui/playwright/themes/vscode-theme-light.css b/webview-ui/playwright/themes/vscode-theme-light.css new file mode 100644 index 0000000000..2b0fcd4157 --- /dev/null +++ b/webview-ui/playwright/themes/vscode-theme-light.css @@ -0,0 +1,780 @@ +/* Generated from Default Light Modern by VS Code 1.100.0. Do not edit manually. */ +.vscode-light { + color-scheme: light; + --vscode-actionBar-toggledBackground: #dddddd; + --vscode-activityBar-activeBorder: #005fb8; + --vscode-activityBar-background: #f8f8f8; + --vscode-activityBar-border: #e5e5e5; + --vscode-activityBar-dropBorder: #1f1f1f; + --vscode-activityBar-foreground: #1f1f1f; + --vscode-activityBar-inactiveForeground: #616161; + --vscode-activityBarBadge-background: #005fb8; + --vscode-activityBarBadge-foreground: #ffffff; + --vscode-activityBarTop-activeBorder: #424242; + --vscode-activityBarTop-dropBorder: #424242; + --vscode-activityBarTop-foreground: #424242; + --vscode-activityBarTop-inactiveForeground: rgba(66, 66, 66, 0.75); + --vscode-activityErrorBadge-background: #e51400; + --vscode-activityErrorBadge-foreground: #ffffff; + --vscode-activityWarningBadge-background: #bf8803; + --vscode-activityWarningBadge-foreground: #ffffff; + --vscode-badge-background: #cccccc; + --vscode-badge-foreground: #3b3b3b; + --vscode-banner-background: #a2a2a2; + --vscode-banner-foreground: #000000; + --vscode-banner-iconForeground: #1a85ff; + --vscode-breadcrumb-activeSelectionForeground: #2f2f2f; + --vscode-breadcrumb-background: #ffffff; + --vscode-breadcrumb-focusForeground: #2f2f2f; + --vscode-breadcrumb-foreground: rgba(59, 59, 59, 0.8); + --vscode-breadcrumbPicker-background: #f8f8f8; + --vscode-button-background: #005fb8; + --vscode-button-border: rgba(0, 0, 0, 0.1); + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #0258a8; + --vscode-button-secondaryBackground: #e5e5e5; + --vscode-button-secondaryForeground: #3b3b3b; + --vscode-button-secondaryHoverBackground: #cccccc; + --vscode-button-separator: rgba(255, 255, 255, 0.4); + --vscode-chart-axis: rgba(0, 0, 0, 0.6); + --vscode-chart-guide: rgba(0, 0, 0, 0.2); + --vscode-chart-line: #236b8e; + --vscode-charts-blue: #1a85ff; + --vscode-charts-foreground: #3b3b3b; + --vscode-charts-green: #388a34; + --vscode-charts-lines: rgba(59, 59, 59, 0.5); + --vscode-charts-orange: #d18616; + --vscode-charts-purple: #652d90; + --vscode-charts-red: #e51400; + --vscode-charts-yellow: #bf8803; + --vscode-chat-avatarBackground: #f2f2f2; + --vscode-chat-avatarForeground: #3b3b3b; + --vscode-chat-editedFileForeground: #895503; + --vscode-chat-requestBackground: rgba(255, 255, 255, 0.62); + --vscode-chat-requestBorder: rgba(0, 0, 0, 0.1); + --vscode-chat-slashCommandBackground: #d2ecff; + --vscode-chat-slashCommandForeground: #306ca2; + --vscode-checkbox-background: #f8f8f8; + --vscode-checkbox-border: #cecece; + --vscode-checkbox-disabled.background: #b9b9b9; + --vscode-checkbox-disabled.foreground: #797979; + --vscode-checkbox-foreground: #3b3b3b; + --vscode-checkbox-selectBackground: #f8f8f8; + --vscode-checkbox-selectBorder: #3b3b3b; + --vscode-commandCenter-activeBackground: rgba(0, 0, 0, 0.08); + --vscode-commandCenter-activeBorder: rgba(30, 30, 30, 0.3); + --vscode-commandCenter-activeForeground: #1e1e1e; + --vscode-commandCenter-background: rgba(0, 0, 0, 0.05); + --vscode-commandCenter-border: rgba(30, 30, 30, 0.2); + --vscode-commandCenter-debuggingBackground: rgba(253, 113, 108, 0.26); + --vscode-commandCenter-foreground: #1e1e1e; + --vscode-commandCenter-inactiveBorder: rgba(139, 148, 158, 0.25); + --vscode-commandCenter-inactiveForeground: #8b949e; + --vscode-commentsView-resolvedIcon: rgba(97, 97, 97, 0.5); + --vscode-commentsView-unresolvedIcon: #005fb8; + --vscode-debugConsole-errorForeground: #f85149; + --vscode-debugConsole-infoForeground: #1a85ff; + --vscode-debugConsole-sourceForeground: #3b3b3b; + --vscode-debugConsole-warningForeground: #bf8803; + --vscode-debugConsoleInputIcon-foreground: #3b3b3b; + --vscode-debugExceptionWidget-background: #f1dfde; + --vscode-debugExceptionWidget-border: #a31515; + --vscode-debugIcon-breakpointCurrentStackframeForeground: #be8700; + --vscode-debugIcon-breakpointDisabledForeground: #848484; + --vscode-debugIcon-breakpointForeground: #e51400; + --vscode-debugIcon-breakpointStackframeForeground: #89d185; + --vscode-debugIcon-breakpointUnverifiedForeground: #848484; + --vscode-debugIcon-continueForeground: #007acc; + --vscode-debugIcon-disconnectForeground: #a1260d; + --vscode-debugIcon-pauseForeground: #007acc; + --vscode-debugIcon-restartForeground: #388a34; + --vscode-debugIcon-startForeground: #388a34; + --vscode-debugIcon-stepBackForeground: #007acc; + --vscode-debugIcon-stepIntoForeground: #007acc; + --vscode-debugIcon-stepOutForeground: #007acc; + --vscode-debugIcon-stepOverForeground: #007acc; + --vscode-debugIcon-stopForeground: #a1260d; + --vscode-debugTokenExpression-boolean: #0000ff; + --vscode-debugTokenExpression-error: #e51400; + --vscode-debugTokenExpression-name: #9b46b0; + --vscode-debugTokenExpression-number: #098658; + --vscode-debugTokenExpression-string: #a31515; + --vscode-debugTokenExpression-type: #4a90e2; + --vscode-debugTokenExpression-value: rgba(108, 108, 108, 0.8); + --vscode-debugToolBar-background: #f3f3f3; + --vscode-debugView-exceptionLabelBackground: #a31515; + --vscode-debugView-exceptionLabelForeground: #ffffff; + --vscode-debugView-stateLabelBackground: rgba(136, 136, 136, 0.27); + --vscode-debugView-stateLabelForeground: #3b3b3b; + --vscode-debugView-valueChangedHighlight: #569cd6; + --vscode-descriptionForeground: #3b3b3b; + --vscode-diffEditor-diagonalFill: rgba(34, 34, 34, 0.2); + --vscode-diffEditor-insertedLineBackground: rgba(155, 185, 85, 0.2); + --vscode-diffEditor-insertedTextBackground: rgba(156, 204, 44, 0.25); + --vscode-diffEditor-move.border: rgba(139, 139, 139, 0.61); + --vscode-diffEditor-moveActive.border: #ffa500; + --vscode-diffEditor-removedLineBackground: rgba(255, 0, 0, 0.2); + --vscode-diffEditor-removedTextBackground: rgba(255, 0, 0, 0.2); + --vscode-diffEditor-unchangedCodeBackground: rgba(184, 184, 184, 0.16); + --vscode-diffEditor-unchangedRegionBackground: #f8f8f8; + --vscode-diffEditor-unchangedRegionForeground: #3b3b3b; + --vscode-diffEditor-unchangedRegionShadow: rgba(115, 115, 115, 0.75); + --vscode-disabledForeground: rgba(97, 97, 97, 0.5); + --vscode-dropdown-background: #ffffff; + --vscode-dropdown-border: #cecece; + --vscode-dropdown-foreground: #3b3b3b; + --vscode-dropdown-listBackground: #ffffff; + --vscode-editor-background: #ffffff; + --vscode-editor-compositionBorder: #000000; + --vscode-editor-findMatchBackground: #a8ac94; + --vscode-editor-findMatchHighlightBackground: rgba(234, 92, 0, 0.33); + --vscode-editor-findRangeHighlightBackground: rgba(180, 180, 180, 0.3); + --vscode-editor-focusedStackFrameHighlightBackground: rgba(206, 231, 206, 0.45); + --vscode-editor-foldBackground: rgba(173, 214, 255, 0.3); + --vscode-editor-foldPlaceholderForeground: #808080; + --vscode-editor-font-size: 14px; + --vscode-editor-font-weight: normal; + --vscode-editor-foreground: #3b3b3b; + --vscode-editor-hoverHighlightBackground: rgba(173, 214, 255, 0.15); + --vscode-editor-inactiveSelectionBackground: #e5ebf1; + --vscode-editor-inlineValuesBackground: rgba(255, 200, 0, 0.2); + --vscode-editor-inlineValuesForeground: rgba(0, 0, 0, 0.5); + --vscode-editor-lineHighlightBorder: #eeeeee; + --vscode-editor-linkedEditingBackground: rgba(255, 0, 0, 0.3); + --vscode-editor-placeholder.foreground: rgba(0, 0, 0, 0.47); + --vscode-editor-rangeHighlightBackground: rgba(253, 255, 0, 0.2); + --vscode-editor-selectionBackground: #add6ff; + --vscode-editor-selectionHighlightBackground: rgba(173, 214, 255, 0.5); + --vscode-editor-snippetFinalTabstopHighlightBorder: rgba(10, 50, 100, 0.5); + --vscode-editor-snippetTabstopHighlightBackground: rgba(10, 50, 100, 0.2); + --vscode-editor-stackFrameHighlightBackground: rgba(255, 255, 102, 0.45); + --vscode-editor-symbolHighlightBackground: rgba(234, 92, 0, 0.33); + --vscode-editor-wordHighlightBackground: rgba(87, 87, 87, 0.25); + --vscode-editor-wordHighlightStrongBackground: rgba(14, 99, 156, 0.25); + --vscode-editor-wordHighlightTextBackground: rgba(87, 87, 87, 0.25); + --vscode-editorActionList-background: #f8f8f8; + --vscode-editorActionList-focusBackground: #e8e8e8; + --vscode-editorActionList-focusForeground: #000000; + --vscode-editorActionList-foreground: #3b3b3b; + --vscode-editorActiveLineNumber-foreground: #0b216f; + --vscode-editorBracketHighlight-foreground1: #0431fa; + --vscode-editorBracketHighlight-foreground2: #319331; + --vscode-editorBracketHighlight-foreground3: #7b3814; + --vscode-editorBracketHighlight-foreground4: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-foreground5: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-foreground6: rgba(0, 0, 0, 0); + --vscode-editorBracketHighlight-unexpectedBracket.foreground: rgba(255, 18, 18, 0.8); + --vscode-editorBracketMatch-background: rgba(0, 100, 0, 0.1); + --vscode-editorBracketMatch-border: #b9b9b9; + --vscode-editorBracketPairGuide-activeBackground1: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground2: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground3: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground4: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground5: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-activeBackground6: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background1: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background2: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background3: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background4: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background5: rgba(0, 0, 0, 0); + --vscode-editorBracketPairGuide-background6: rgba(0, 0, 0, 0); + --vscode-editorCodeLens-foreground: #919191; + --vscode-editorCommentsWidget-rangeActiveBackground: rgba(0, 95, 184, 0.1); + --vscode-editorCommentsWidget-rangeBackground: rgba(0, 95, 184, 0.1); + --vscode-editorCommentsWidget-replyInputBackground: #f3f3f3; + --vscode-editorCommentsWidget-resolvedBorder: rgba(97, 97, 97, 0.5); + --vscode-editorCommentsWidget-unresolvedBorder: #005fb8; + --vscode-editorCursor-foreground: #000000; + --vscode-editorError-foreground: #e51400; + --vscode-editorGhostText-foreground: rgba(0, 0, 0, 0.47); + --vscode-editorGroup-border: #e5e5e5; + --vscode-editorGroup-dropBackground: rgba(38, 119, 203, 0.18); + --vscode-editorGroup-dropIntoPromptBackground: #f8f8f8; + --vscode-editorGroup-dropIntoPromptForeground: #3b3b3b; + --vscode-editorGroupHeader-noTabsBackground: #ffffff; + --vscode-editorGroupHeader-tabsBackground: #f8f8f8; + --vscode-editorGroupHeader-tabsBorder: #e5e5e5; + --vscode-editorGutter-addedBackground: #2ea043; + --vscode-editorGutter-addedSecondaryBackground: #83db93; + --vscode-editorGutter-background: #ffffff; + --vscode-editorGutter-commentGlyphForeground: #3b3b3b; + --vscode-editorGutter-commentRangeForeground: #d5d8e9; + --vscode-editorGutter-commentUnresolvedGlyphForeground: #3b3b3b; + --vscode-editorGutter-deletedBackground: #f85149; + --vscode-editorGutter-deletedSecondaryBackground: #fcaaa6; + --vscode-editorGutter-foldingControlForeground: #3b3b3b; + --vscode-editorGutter-itemBackground: #d5d8e9; + --vscode-editorGutter-itemGlyphForeground: #3b3b3b; + --vscode-editorGutter-modifiedBackground: #005fb8; + --vscode-editorGutter-modifiedSecondaryBackground: #3aa0ff; + --vscode-editorHint-foreground: #6c6c6c; + --vscode-editorHoverWidget-background: #f8f8f8; + --vscode-editorHoverWidget-border: #c8c8c8; + --vscode-editorHoverWidget-foreground: #3b3b3b; + --vscode-editorHoverWidget-highlightForeground: #0066bf; + --vscode-editorHoverWidget-statusBarBackground: #ececec; + --vscode-editorIndentGuide-activeBackground: rgba(51, 51, 51, 0.2); + --vscode-editorIndentGuide-activeBackground1: #939393; + --vscode-editorIndentGuide-activeBackground2: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground3: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground4: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground5: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-activeBackground6: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background: rgba(51, 51, 51, 0.2); + --vscode-editorIndentGuide-background1: #d3d3d3; + --vscode-editorIndentGuide-background2: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background3: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background4: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background5: rgba(0, 0, 0, 0); + --vscode-editorIndentGuide-background6: rgba(0, 0, 0, 0); + --vscode-editorInfo-foreground: #1a85ff; + --vscode-editorInlayHint-background: rgba(204, 204, 204, 0.1); + --vscode-editorInlayHint-foreground: #969696; + --vscode-editorInlayHint-parameterBackground: rgba(204, 204, 204, 0.1); + --vscode-editorInlayHint-parameterForeground: #969696; + --vscode-editorInlayHint-typeBackground: rgba(204, 204, 204, 0.1); + --vscode-editorInlayHint-typeForeground: #969696; + --vscode-editorLightBulb-foreground: #ddb100; + --vscode-editorLightBulbAi-foreground: #ddb100; + --vscode-editorLightBulbAutoFix-foreground: #007acc; + --vscode-editorLineNumber-activeForeground: #171184; + --vscode-editorLineNumber-foreground: #6e7681; + --vscode-editorLink-activeForeground: #0000ff; + --vscode-editorMarkerNavigation-background: #ffffff; + --vscode-editorMarkerNavigationError-background: #e51400; + --vscode-editorMarkerNavigationError-headerBackground: rgba(229, 20, 0, 0.1); + --vscode-editorMarkerNavigationInfo-background: #1a85ff; + --vscode-editorMarkerNavigationInfo-headerBackground: rgba(26, 133, 255, 0.1); + --vscode-editorMarkerNavigationWarning-background: #bf8803; + --vscode-editorMarkerNavigationWarning-headerBackground: rgba(191, 136, 3, 0.1); + --vscode-editorMinimap-inlineChatInserted: rgba(156, 204, 44, 0.2); + --vscode-editorMultiCursor-primary.foreground: #000000; + --vscode-editorMultiCursor-secondary.foreground: #000000; + --vscode-editorOverviewRuler-addedForeground: rgba(46, 160, 67, 0.6); + --vscode-editorOverviewRuler-border: #e5e5e5; + --vscode-editorOverviewRuler-bracketMatchForeground: #a0a0a0; + --vscode-editorOverviewRuler-commentForeground: #d5d8e9; + --vscode-editorOverviewRuler-commentUnresolvedForeground: #d5d8e9; + --vscode-editorOverviewRuler-commonContentForeground: rgba(96, 96, 96, 0.4); + --vscode-editorOverviewRuler-currentContentForeground: rgba(64, 200, 174, 0.5); + --vscode-editorOverviewRuler-deletedForeground: rgba(248, 81, 73, 0.6); + --vscode-editorOverviewRuler-errorForeground: rgba(255, 18, 18, 0.7); + --vscode-editorOverviewRuler-findMatchForeground: rgba(209, 134, 22, 0.49); + --vscode-editorOverviewRuler-incomingContentForeground: rgba(64, 166, 255, 0.5); + --vscode-editorOverviewRuler-infoForeground: #1a85ff; + --vscode-editorOverviewRuler-inlineChatInserted: rgba(156, 204, 44, 0.2); + --vscode-editorOverviewRuler-inlineChatRemoved: rgba(255, 0, 0, 0.16); + --vscode-editorOverviewRuler-modifiedForeground: rgba(0, 95, 184, 0.6); + --vscode-editorOverviewRuler-rangeHighlightForeground: rgba(0, 122, 204, 0.6); + --vscode-editorOverviewRuler-selectionHighlightForeground: rgba(160, 160, 160, 0.8); + --vscode-editorOverviewRuler-warningForeground: #bf8803; + --vscode-editorOverviewRuler-wordHighlightForeground: rgba(160, 160, 160, 0.8); + --vscode-editorOverviewRuler-wordHighlightStrongForeground: rgba(192, 160, 192, 0.8); + --vscode-editorOverviewRuler-wordHighlightTextForeground: rgba(160, 160, 160, 0.8); + --vscode-editorPane-background: #ffffff; + --vscode-editorRuler-foreground: #d3d3d3; + --vscode-editorStickyScroll-background: #ffffff; + --vscode-editorStickyScroll-shadow: #dddddd; + --vscode-editorStickyScrollHover-background: #f0f0f0; + --vscode-editorSuggestWidget-background: #f8f8f8; + --vscode-editorSuggestWidget-border: #c8c8c8; + --vscode-editorSuggestWidget-focusHighlightForeground: #0066bf; + --vscode-editorSuggestWidget-foreground: #3b3b3b; + --vscode-editorSuggestWidget-highlightForeground: #0066bf; + --vscode-editorSuggestWidget-selectedBackground: #e8e8e8; + --vscode-editorSuggestWidget-selectedForeground: #000000; + --vscode-editorSuggestWidget-selectedIconForeground: #000000; + --vscode-editorSuggestWidgetStatus-foreground: rgba(59, 59, 59, 0.5); + --vscode-editorUnicodeHighlight-border: #bf8803; + --vscode-editorUnnecessaryCode-opacity: rgba(0, 0, 0, 0.47); + --vscode-editorWarning-foreground: #bf8803; + --vscode-editorWatermark-foreground: rgba(59, 59, 59, 0.68); + --vscode-editorWhitespace-foreground: rgba(51, 51, 51, 0.2); + --vscode-editorWidget-background: #f8f8f8; + --vscode-editorWidget-border: #c8c8c8; + --vscode-editorWidget-foreground: #3b3b3b; + --vscode-errorForeground: #f85149; + --vscode-extensionBadge-remoteBackground: #005fb8; + --vscode-extensionBadge-remoteForeground: #ffffff; + --vscode-extensionButton-background: #005fb8; + --vscode-extensionButton-foreground: #ffffff; + --vscode-extensionButton-hoverBackground: #0258a8; + --vscode-extensionButton-prominentBackground: #005fb8; + --vscode-extensionButton-prominentForeground: #ffffff; + --vscode-extensionButton-prominentHoverBackground: #0258a8; + --vscode-extensionButton-separator: rgba(255, 255, 255, 0.4); + --vscode-extensionIcon-preReleaseForeground: #1d9271; + --vscode-extensionIcon-privateForeground: rgba(0, 0, 0, 0.38); + --vscode-extensionIcon-sponsorForeground: #b51e78; + --vscode-extensionIcon-starForeground: #df6100; + --vscode-extensionIcon-verifiedForeground: #005fb8; + --vscode-focusBorder: #005fb8; + --vscode-font-size: 13px; + --vscode-font-weight: normal; + --vscode-foreground: #3b3b3b; + --vscode-gauge-background: #007acc; + --vscode-gauge-errorBackground: #be1100; + --vscode-gauge-errorForeground: rgba(190, 17, 0, 0.3); + --vscode-gauge-foreground: rgba(0, 122, 204, 0.3); + --vscode-gauge-warningBackground: #b89500; + --vscode-gauge-warningForeground: rgba(184, 149, 0, 0.3); + --vscode-git-blame.editorDecorationForeground: #969696; + --vscode-gitDecoration-addedResourceForeground: #587c0c; + --vscode-gitDecoration-conflictingResourceForeground: #ad0707; + --vscode-gitDecoration-deletedResourceForeground: #ad0707; + --vscode-gitDecoration-ignoredResourceForeground: #8e8e90; + --vscode-gitDecoration-modifiedResourceForeground: #895503; + --vscode-gitDecoration-renamedResourceForeground: #007100; + --vscode-gitDecoration-stageDeletedResourceForeground: #ad0707; + --vscode-gitDecoration-stageModifiedResourceForeground: #895503; + --vscode-gitDecoration-submoduleResourceForeground: #1258a7; + --vscode-gitDecoration-untrackedResourceForeground: #007100; + --vscode-icon-foreground: #3b3b3b; + --vscode-inlineChat-background: #f8f8f8; + --vscode-inlineChat-border: #c8c8c8; + --vscode-inlineChat-foreground: #3b3b3b; + --vscode-inlineChat-shadow: rgba(0, 0, 0, 0.16); + --vscode-inlineChatDiff-inserted: rgba(156, 204, 44, 0.13); + --vscode-inlineChatDiff-removed: rgba(255, 0, 0, 0.1); + --vscode-inlineChatInput-background: #ffffff; + --vscode-inlineChatInput-border: #c8c8c8; + --vscode-inlineChatInput-focusBorder: #005fb8; + --vscode-inlineChatInput-placeholderForeground: #767676; + --vscode-inlineEdit-gutterIndicator.background: rgba(95, 95, 95, 0.09); + --vscode-inlineEdit-gutterIndicator.primaryBackground: rgba(0, 95, 184, 0.5); + --vscode-inlineEdit-gutterIndicator.primaryBorder: #005fb8; + --vscode-inlineEdit-gutterIndicator.primaryForeground: #ffffff; + --vscode-inlineEdit-gutterIndicator.secondaryBackground: #e5e5e5; + --vscode-inlineEdit-gutterIndicator.secondaryBorder: #e5e5e5; + --vscode-inlineEdit-gutterIndicator.secondaryForeground: #3b3b3b; + --vscode-inlineEdit-gutterIndicator.successfulBackground: #005fb8; + --vscode-inlineEdit-gutterIndicator.successfulBorder: #005fb8; + --vscode-inlineEdit-gutterIndicator.successfulForeground: #ffffff; + --vscode-inlineEdit-modifiedBackground: rgba(156, 204, 44, 0.07); + --vscode-inlineEdit-modifiedBorder: rgba(62, 81, 18, 0.25); + --vscode-inlineEdit-modifiedChangedLineBackground: rgba(155, 185, 85, 0.14); + --vscode-inlineEdit-modifiedChangedTextBackground: rgba(156, 204, 44, 0.18); + --vscode-inlineEdit-originalBackground: rgba(255, 0, 0, 0.04); + --vscode-inlineEdit-originalBorder: rgba(255, 0, 0, 0.2); + --vscode-inlineEdit-originalChangedLineBackground: rgba(255, 0, 0, 0.16); + --vscode-inlineEdit-originalChangedTextBackground: rgba(255, 0, 0, 0.16); + --vscode-inlineEdit-tabWillAcceptModifiedBorder: rgba(62, 81, 18, 0.25); + --vscode-inlineEdit-tabWillAcceptOriginalBorder: rgba(255, 0, 0, 0.2); + --vscode-input-background: #ffffff; + --vscode-input-border: #cecece; + --vscode-input-foreground: #3b3b3b; + --vscode-input-placeholderForeground: #767676; + --vscode-inputOption-activeBackground: #bed6ed; + --vscode-inputOption-activeBorder: #005fb8; + --vscode-inputOption-activeForeground: #000000; + --vscode-inputOption-hoverBackground: rgba(184, 184, 184, 0.31); + --vscode-inputValidation-errorBackground: #f2dede; + --vscode-inputValidation-errorBorder: #be1100; + --vscode-inputValidation-infoBackground: #d6ecf2; + --vscode-inputValidation-infoBorder: #007acc; + --vscode-inputValidation-warningBackground: #f6f5d2; + --vscode-inputValidation-warningBorder: #b89500; + --vscode-interactive-activeCodeBorder: #007acc; + --vscode-interactive-inactiveCodeBorder: #e4e6f1; + --vscode-keybindingLabel-background: rgba(221, 221, 221, 0.4); + --vscode-keybindingLabel-border: rgba(204, 204, 204, 0.4); + --vscode-keybindingLabel-bottomBorder: rgba(187, 187, 187, 0.4); + --vscode-keybindingLabel-foreground: #3b3b3b; + --vscode-keybindingTable-headerBackground: rgba(59, 59, 59, 0.04); + --vscode-keybindingTable-rowsBackground: rgba(59, 59, 59, 0.04); + --vscode-list-activeSelectionBackground: #e8e8e8; + --vscode-list-activeSelectionForeground: #000000; + --vscode-list-activeSelectionIconForeground: #000000; + --vscode-list-deemphasizedForeground: #8e8e90; + --vscode-list-dropBackground: #d6ebff; + --vscode-list-dropBetweenBackground: #3b3b3b; + --vscode-list-errorForeground: #b01011; + --vscode-list-filterMatchBackground: rgba(234, 92, 0, 0.33); + --vscode-list-focusAndSelectionOutline: #005fb8; + --vscode-list-focusHighlightForeground: #0066bf; + --vscode-list-focusOutline: #005fb8; + --vscode-list-highlightForeground: #0066bf; + --vscode-list-hoverBackground: #f2f2f2; + --vscode-list-inactiveSelectionBackground: #e4e6f1; + --vscode-list-invalidItemForeground: #b89500; + --vscode-list-warningForeground: #855f00; + --vscode-listFilterWidget-background: #f8f8f8; + --vscode-listFilterWidget-noMatchesOutline: #be1100; + --vscode-listFilterWidget-outline: rgba(0, 0, 0, 0); + --vscode-listFilterWidget-shadow: rgba(0, 0, 0, 0.16); + --vscode-menu-background: #ffffff; + --vscode-menu-border: #cecece; + --vscode-menu-foreground: #3b3b3b; + --vscode-menu-selectionBackground: #005fb8; + --vscode-menu-selectionForeground: #ffffff; + --vscode-menu-separatorBackground: #d4d4d4; + --vscode-menubar-selectionBackground: rgba(184, 184, 184, 0.31); + --vscode-menubar-selectionForeground: #1e1e1e; + --vscode-merge-commonContentBackground: rgba(96, 96, 96, 0.16); + --vscode-merge-commonHeaderBackground: rgba(96, 96, 96, 0.4); + --vscode-merge-currentContentBackground: rgba(64, 200, 174, 0.2); + --vscode-merge-currentHeaderBackground: rgba(64, 200, 174, 0.5); + --vscode-merge-incomingContentBackground: rgba(64, 166, 255, 0.2); + --vscode-merge-incomingHeaderBackground: rgba(64, 166, 255, 0.5); + --vscode-mergeEditor-change.background: rgba(155, 185, 85, 0.2); + --vscode-mergeEditor-change.word.background: rgba(156, 204, 44, 0.4); + --vscode-mergeEditor-changeBase.background: #ffcccc; + --vscode-mergeEditor-changeBase.word.background: #ffa3a3; + --vscode-mergeEditor-conflict.handled.minimapOverViewRuler: rgba(173, 172, 168, 0.93); + --vscode-mergeEditor-conflict.handledFocused.border: rgba(193, 193, 193, 0.8); + --vscode-mergeEditor-conflict.handledUnfocused.border: rgba(134, 134, 134, 0.29); + --vscode-mergeEditor-conflict.input1.background: rgba(64, 200, 174, 0.2); + --vscode-mergeEditor-conflict.input2.background: rgba(64, 166, 255, 0.2); + --vscode-mergeEditor-conflict.unhandled.minimapOverViewRuler: #fcba03; + --vscode-mergeEditor-conflict.unhandledFocused.border: #ffa600; + --vscode-mergeEditor-conflict.unhandledUnfocused.border: #ffa600; + --vscode-mergeEditor-conflictingLines.background: rgba(255, 234, 0, 0.28); + --vscode-minimap-chatEditHighlight: rgba(255, 255, 255, 0.6); + --vscode-minimap-errorHighlight: rgba(255, 18, 18, 0.7); + --vscode-minimap-findMatchHighlight: #d18616; + --vscode-minimap-foregroundOpacity: #000000; + --vscode-minimap-infoHighlight: #1a85ff; + --vscode-minimap-selectionHighlight: #add6ff; + --vscode-minimap-selectionOccurrenceHighlight: #c9c9c9; + --vscode-minimap-warningHighlight: #bf8803; + --vscode-minimapGutter-addedBackground: #2ea043; + --vscode-minimapGutter-deletedBackground: #f85149; + --vscode-minimapGutter-modifiedBackground: #005fb8; + --vscode-minimapSlider-activeBackground: rgba(0, 0, 0, 0.3); + --vscode-minimapSlider-background: rgba(100, 100, 100, 0.2); + --vscode-minimapSlider-hoverBackground: rgba(100, 100, 100, 0.35); + --vscode-multiDiffEditor-background: #ffffff; + --vscode-multiDiffEditor-border: #cccccc; + --vscode-multiDiffEditor-headerBackground: #f8f8f8; + --vscode-notebook-cellBorderColor: #e5e5e5; + --vscode-notebook-cellEditorBackground: #f8f8f8; + --vscode-notebook-cellInsertionIndicator: #005fb8; + --vscode-notebook-cellStatusBarItemHoverBackground: rgba(0, 0, 0, 0.08); + --vscode-notebook-cellToolbarSeparator: rgba(128, 128, 128, 0.35); + --vscode-notebook-editorBackground: #ffffff; + --vscode-notebook-focusedCellBorder: #005fb8; + --vscode-notebook-focusedEditorBorder: #005fb8; + --vscode-notebook-inactiveFocusedCellBorder: #e5e5e5; + --vscode-notebook-selectedCellBackground: rgba(200, 221, 241, 0.31); + --vscode-notebook-selectedCellBorder: #e5e5e5; + --vscode-notebook-symbolHighlightBackground: rgba(253, 255, 0, 0.2); + --vscode-notebookEditorOverviewRuler-runningCellForeground: #388a34; + --vscode-notebookScrollbarSlider-activeBackground: rgba(0, 0, 0, 0.6); + --vscode-notebookScrollbarSlider-background: rgba(100, 100, 100, 0.4); + --vscode-notebookScrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-notebookStatusErrorIcon-foreground: #f85149; + --vscode-notebookStatusRunningIcon-foreground: #3b3b3b; + --vscode-notebookStatusSuccessIcon-foreground: #388a34; + --vscode-notificationCenter-border: #e5e5e5; + --vscode-notificationCenterHeader-background: #ffffff; + --vscode-notificationCenterHeader-foreground: #3b3b3b; + --vscode-notificationLink-foreground: #005fb8; + --vscode-notificationToast-border: #e5e5e5; + --vscode-notifications-background: #ffffff; + --vscode-notifications-border: #e5e5e5; + --vscode-notifications-foreground: #3b3b3b; + --vscode-notificationsErrorIcon-foreground: #e51400; + --vscode-notificationsInfoIcon-foreground: #1a85ff; + --vscode-notificationsWarningIcon-foreground: #bf8803; + --vscode-panel-background: #f8f8f8; + --vscode-panel-border: #e5e5e5; + --vscode-panel-dropBorder: #3b3b3b; + --vscode-panelInput-border: #e5e5e5; + --vscode-panelSection-border: #e5e5e5; + --vscode-panelSection-dropBackground: rgba(38, 119, 203, 0.18); + --vscode-panelSectionHeader-background: rgba(128, 128, 128, 0.2); + --vscode-panelStickyScroll-background: #f8f8f8; + --vscode-panelStickyScroll-shadow: #dddddd; + --vscode-panelTitle-activeBorder: #005fb8; + --vscode-panelTitle-activeForeground: #3b3b3b; + --vscode-panelTitle-inactiveForeground: #3b3b3b; + --vscode-panelTitleBadge-background: #005fb8; + --vscode-panelTitleBadge-foreground: #ffffff; + --vscode-peekView-border: #1a85ff; + --vscode-peekViewEditor-background: #f2f8fc; + --vscode-peekViewEditor-matchHighlightBackground: rgba(187, 128, 9, 0.4); + --vscode-peekViewEditorGutter-background: #f2f8fc; + --vscode-peekViewEditorStickyScroll-background: #f2f8fc; + --vscode-peekViewResult-background: #ffffff; + --vscode-peekViewResult-fileForeground: #1e1e1e; + --vscode-peekViewResult-lineForeground: #646465; + --vscode-peekViewResult-matchHighlightBackground: rgba(187, 128, 9, 0.4); + --vscode-peekViewResult-selectionBackground: rgba(51, 153, 255, 0.2); + --vscode-peekViewResult-selectionForeground: #6c6c6c; + --vscode-peekViewTitle-background: #f3f3f3; + --vscode-peekViewTitleDescription-foreground: #616161; + --vscode-peekViewTitleLabel-foreground: #000000; + --vscode-pickerGroup-border: #e5e5e5; + --vscode-pickerGroup-foreground: #8b949e; + --vscode-ports-iconRunningProcessForeground: #369432; + --vscode-problemsErrorIcon-foreground: #e51400; + --vscode-problemsInfoIcon-foreground: #1a85ff; + --vscode-problemsWarningIcon-foreground: #bf8803; + --vscode-profileBadge-background: #c4c4c4; + --vscode-profileBadge-foreground: #333333; + --vscode-profiles-sashBorder: #e5e5e5; + --vscode-progressBar-background: #005fb8; + --vscode-prompt-frontMatter.background: #f2f2f2; + --vscode-prompt-frontMatter.inactiveBackground: #f9f9f9; + --vscode-quickInput-background: #f8f8f8; + --vscode-quickInput-foreground: #3b3b3b; + --vscode-quickInputList-focusBackground: #e8e8e8; + --vscode-quickInputList-focusForeground: #000000; + --vscode-quickInputList-focusIconForeground: #000000; + --vscode-quickInputTitle-background: rgba(0, 0, 0, 0.06); + --vscode-radio-activeBackground: #bed6ed; + --vscode-radio-activeBorder: #005fb8; + --vscode-radio-activeForeground: #000000; + --vscode-radio-inactiveBorder: rgba(0, 0, 0, 0.2); + --vscode-radio-inactiveHoverBackground: rgba(184, 184, 184, 0.31); + --vscode-sash-hoverBorder: #005fb8; + --vscode-scmGraph-foreground1: #ffb000; + --vscode-scmGraph-foreground2: #dc267f; + --vscode-scmGraph-foreground3: #994f00; + --vscode-scmGraph-foreground4: #40b0a6; + --vscode-scmGraph-foreground5: #b66dff; + --vscode-scmGraph-historyItemBaseRefColor: #ea5c00; + --vscode-scmGraph-historyItemHoverAdditionsForeground: #587c0c; + --vscode-scmGraph-historyItemHoverDefaultLabelBackground: #cccccc; + --vscode-scmGraph-historyItemHoverDefaultLabelForeground: #3b3b3b; + --vscode-scmGraph-historyItemHoverDeletionsForeground: #ad0707; + --vscode-scmGraph-historyItemHoverLabelForeground: #ffffff; + --vscode-scmGraph-historyItemRefColor: #1a85ff; + --vscode-scmGraph-historyItemRemoteRefColor: #652d90; + --vscode-scrollbar-shadow: #dddddd; + --vscode-scrollbarSlider-activeBackground: rgba(0, 0, 0, 0.6); + --vscode-scrollbarSlider-background: rgba(100, 100, 100, 0.4); + --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-search-resultsInfoForeground: #3b3b3b; + --vscode-searchEditor-findMatchBackground: rgba(234, 92, 0, 0.22); + --vscode-searchEditor-textInputBorder: #cecece; + --vscode-settings-checkboxBackground: #f8f8f8; + --vscode-settings-checkboxBorder: #cecece; + --vscode-settings-checkboxForeground: #3b3b3b; + --vscode-settings-dropdownBackground: #ffffff; + --vscode-settings-dropdownBorder: #cecece; + --vscode-settings-dropdownForeground: #3b3b3b; + --vscode-settings-dropdownListBorder: #c8c8c8; + --vscode-settings-focusedRowBackground: rgba(242, 242, 242, 0.6); + --vscode-settings-focusedRowBorder: #005fb8; + --vscode-settings-headerBorder: #e5e5e5; + --vscode-settings-headerForeground: #1f1f1f; + --vscode-settings-modifiedItemIndicator: rgba(187, 128, 9, 0.4); + --vscode-settings-numberInputBackground: #ffffff; + --vscode-settings-numberInputBorder: #cecece; + --vscode-settings-numberInputForeground: #3b3b3b; + --vscode-settings-rowHoverBackground: rgba(242, 242, 242, 0.3); + --vscode-settings-sashBorder: #e5e5e5; + --vscode-settings-settingsHeaderHoverForeground: rgba(31, 31, 31, 0.7); + --vscode-settings-textInputBackground: #ffffff; + --vscode-settings-textInputBorder: #cecece; + --vscode-settings-textInputForeground: #3b3b3b; + --vscode-sideBar-background: #f8f8f8; + --vscode-sideBar-border: #e5e5e5; + --vscode-sideBar-dropBackground: rgba(38, 119, 203, 0.18); + --vscode-sideBar-foreground: #3b3b3b; + --vscode-sideBarActivityBarTop-border: #e5e5e5; + --vscode-sideBarSectionHeader-background: #f8f8f8; + --vscode-sideBarSectionHeader-border: #e5e5e5; + --vscode-sideBarSectionHeader-foreground: #3b3b3b; + --vscode-sideBarStickyScroll-background: #f8f8f8; + --vscode-sideBarStickyScroll-shadow: #dddddd; + --vscode-sideBarTitle-background: #f8f8f8; + --vscode-sideBarTitle-foreground: #3b3b3b; + --vscode-sideBySideEditor-horizontalBorder: #e5e5e5; + --vscode-sideBySideEditor-verticalBorder: #e5e5e5; + --vscode-simpleFindWidget-sashBorder: #c8c8c8; + --vscode-statusBar-background: #f8f8f8; + --vscode-statusBar-border: #e5e5e5; + --vscode-statusBar-debuggingBackground: #fd716c; + --vscode-statusBar-debuggingBorder: #e5e5e5; + --vscode-statusBar-debuggingForeground: #000000; + --vscode-statusBar-focusBorder: #005fb8; + --vscode-statusBar-foreground: #3b3b3b; + --vscode-statusBar-noFolderBackground: #f8f8f8; + --vscode-statusBar-noFolderBorder: #e5e5e5; + --vscode-statusBar-noFolderForeground: #3b3b3b; + --vscode-statusBarItem-activeBackground: rgba(255, 255, 255, 0.18); + --vscode-statusBarItem-compactHoverBackground: #cccccc; + --vscode-statusBarItem-errorBackground: #c72e0f; + --vscode-statusBarItem-errorForeground: #ffffff; + --vscode-statusBarItem-errorHoverBackground: rgba(184, 184, 184, 0.31); + --vscode-statusBarItem-errorHoverForeground: #3b3b3b; + --vscode-statusBarItem-focusBorder: #005fb8; + --vscode-statusBarItem-hoverBackground: rgba(184, 184, 184, 0.31); + --vscode-statusBarItem-hoverForeground: #3b3b3b; + --vscode-statusBarItem-offlineBackground: #6c1717; + --vscode-statusBarItem-offlineForeground: #ffffff; + --vscode-statusBarItem-offlineHoverBackground: rgba(184, 184, 184, 0.31); + --vscode-statusBarItem-offlineHoverForeground: #3b3b3b; + --vscode-statusBarItem-prominentBackground: rgba(110, 118, 129, 0.4); + --vscode-statusBarItem-prominentForeground: #3b3b3b; + --vscode-statusBarItem-prominentHoverBackground: rgba(184, 184, 184, 0.31); + --vscode-statusBarItem-prominentHoverForeground: #3b3b3b; + --vscode-statusBarItem-remoteBackground: #005fb8; + --vscode-statusBarItem-remoteForeground: #ffffff; + --vscode-statusBarItem-remoteHoverBackground: rgba(184, 184, 184, 0.31); + --vscode-statusBarItem-remoteHoverForeground: #3b3b3b; + --vscode-statusBarItem-warningBackground: #725102; + --vscode-statusBarItem-warningForeground: #ffffff; + --vscode-statusBarItem-warningHoverBackground: rgba(184, 184, 184, 0.31); + --vscode-statusBarItem-warningHoverForeground: #3b3b3b; + --vscode-symbolIcon-arrayForeground: #3b3b3b; + --vscode-symbolIcon-booleanForeground: #3b3b3b; + --vscode-symbolIcon-classForeground: #d67e00; + --vscode-symbolIcon-colorForeground: #3b3b3b; + --vscode-symbolIcon-constantForeground: #3b3b3b; + --vscode-symbolIcon-constructorForeground: #652d90; + --vscode-symbolIcon-enumeratorForeground: #d67e00; + --vscode-symbolIcon-enumeratorMemberForeground: #007acc; + --vscode-symbolIcon-eventForeground: #d67e00; + --vscode-symbolIcon-fieldForeground: #007acc; + --vscode-symbolIcon-fileForeground: #3b3b3b; + --vscode-symbolIcon-folderForeground: #3b3b3b; + --vscode-symbolIcon-functionForeground: #652d90; + --vscode-symbolIcon-interfaceForeground: #007acc; + --vscode-symbolIcon-keyForeground: #3b3b3b; + --vscode-symbolIcon-keywordForeground: #3b3b3b; + --vscode-symbolIcon-methodForeground: #652d90; + --vscode-symbolIcon-moduleForeground: #3b3b3b; + --vscode-symbolIcon-namespaceForeground: #3b3b3b; + --vscode-symbolIcon-nullForeground: #3b3b3b; + --vscode-symbolIcon-numberForeground: #3b3b3b; + --vscode-symbolIcon-objectForeground: #3b3b3b; + --vscode-symbolIcon-operatorForeground: #3b3b3b; + --vscode-symbolIcon-packageForeground: #3b3b3b; + --vscode-symbolIcon-propertyForeground: #3b3b3b; + --vscode-symbolIcon-referenceForeground: #3b3b3b; + --vscode-symbolIcon-snippetForeground: #3b3b3b; + --vscode-symbolIcon-stringForeground: #3b3b3b; + --vscode-symbolIcon-structForeground: #3b3b3b; + --vscode-symbolIcon-textForeground: #3b3b3b; + --vscode-symbolIcon-typeParameterForeground: #3b3b3b; + --vscode-symbolIcon-unitForeground: #3b3b3b; + --vscode-symbolIcon-variableForeground: #007acc; + --vscode-tab-activeBackground: #ffffff; + --vscode-tab-activeBorder: #f8f8f8; + --vscode-tab-activeBorderTop: #005fb8; + --vscode-tab-activeForeground: #3b3b3b; + --vscode-tab-activeModifiedBorder: #33aaee; + --vscode-tab-border: #e5e5e5; + --vscode-tab-dragAndDropBorder: #3b3b3b; + --vscode-tab-hoverBackground: #ffffff; + --vscode-tab-inactiveBackground: #f8f8f8; + --vscode-tab-inactiveForeground: #868686; + --vscode-tab-inactiveModifiedBorder: rgba(51, 170, 238, 0.5); + --vscode-tab-lastPinnedBorder: #d4d4d4; + --vscode-tab-selectedBackground: rgba(255, 255, 255, 0.65); + --vscode-tab-selectedBorderTop: #68a3da; + --vscode-tab-selectedForeground: rgba(51, 51, 51, 0.7); + --vscode-tab-unfocusedActiveBackground: #ffffff; + --vscode-tab-unfocusedActiveBorder: #f8f8f8; + --vscode-tab-unfocusedActiveBorderTop: #e5e5e5; + --vscode-tab-unfocusedActiveForeground: rgba(59, 59, 59, 0.7); + --vscode-tab-unfocusedActiveModifiedBorder: rgba(51, 170, 238, 0.7); + --vscode-tab-unfocusedHoverBackground: #f8f8f8; + --vscode-tab-unfocusedInactiveBackground: #f8f8f8; + --vscode-tab-unfocusedInactiveForeground: rgba(134, 134, 134, 0.5); + --vscode-tab-unfocusedInactiveModifiedBorder: rgba(51, 170, 238, 0.25); + --vscode-terminal-ansiBlack: #000000; + --vscode-terminal-ansiBlue: #0451a5; + --vscode-terminal-ansiBrightBlack: #666666; + --vscode-terminal-ansiBrightBlue: #0451a5; + --vscode-terminal-ansiBrightCyan: #0598bc; + --vscode-terminal-ansiBrightGreen: #14ce14; + --vscode-terminal-ansiBrightMagenta: #bc05bc; + --vscode-terminal-ansiBrightRed: #cd3131; + --vscode-terminal-ansiBrightWhite: #a5a5a5; + --vscode-terminal-ansiBrightYellow: #b5ba00; + --vscode-terminal-ansiCyan: #0598bc; + --vscode-terminal-ansiGreen: #107c10; + --vscode-terminal-ansiMagenta: #bc05bc; + --vscode-terminal-ansiRed: #cd3131; + --vscode-terminal-ansiWhite: #555555; + --vscode-terminal-ansiYellow: #949800; + --vscode-terminal-border: #e5e5e5; + --vscode-terminal-dropBackground: rgba(38, 119, 203, 0.18); + --vscode-terminal-findMatchBackground: #a8ac94; + --vscode-terminal-findMatchHighlightBackground: rgba(234, 92, 0, 0.33); + --vscode-terminal-foreground: #3b3b3b; + --vscode-terminal-hoverHighlightBackground: rgba(173, 214, 255, 0.07); + --vscode-terminal-inactiveSelectionBackground: #e5ebf1; + --vscode-terminal-initialHintForeground: rgba(0, 0, 0, 0.47); + --vscode-terminal-selectionBackground: #add6ff; + --vscode-terminal-tab.activeBorder: #005fb8; + --vscode-terminalCommandDecoration-defaultBackground: rgba(0, 0, 0, 0.25); + --vscode-terminalCommandDecoration-errorBackground: #e51400; + --vscode-terminalCommandDecoration-successBackground: #2090d3; + --vscode-terminalCommandGuide-foreground: #e4e6f1; + --vscode-terminalCursor-foreground: #005fb8; + --vscode-terminalOverviewRuler-border: #e5e5e5; + --vscode-terminalOverviewRuler-cursorForeground: rgba(160, 160, 160, 0.8); + --vscode-terminalOverviewRuler-findMatchForeground: rgba(209, 134, 22, 0.49); + --vscode-terminalStickyScrollHover-background: #f0f0f0; + --vscode-terminalSymbolIcon-aliasForeground: #652d90; + --vscode-terminalSymbolIcon-argumentForeground: #007acc; + --vscode-terminalSymbolIcon-fileForeground: #3b3b3b; + --vscode-terminalSymbolIcon-flagForeground: #d67e00; + --vscode-terminalSymbolIcon-folderForeground: #3b3b3b; + --vscode-terminalSymbolIcon-methodForeground: #652d90; + --vscode-terminalSymbolIcon-optionForeground: #d67e00; + --vscode-terminalSymbolIcon-optionValueForeground: #007acc; + --vscode-testing-coverCountBadgeBackground: #cccccc; + --vscode-testing-coverCountBadgeForeground: #3b3b3b; + --vscode-testing-coveredBackground: rgba(156, 204, 44, 0.25); + --vscode-testing-coveredBorder: rgba(156, 204, 44, 0.19); + --vscode-testing-coveredGutterBackground: rgba(156, 204, 44, 0.15); + --vscode-testing-iconErrored: #f14c4c; + --vscode-testing-iconErrored.retired: rgba(241, 76, 76, 0.7); + --vscode-testing-iconFailed: #f14c4c; + --vscode-testing-iconFailed.retired: rgba(241, 76, 76, 0.7); + --vscode-testing-iconPassed: #73c991; + --vscode-testing-iconPassed.retired: rgba(115, 201, 145, 0.7); + --vscode-testing-iconQueued: #cca700; + --vscode-testing-iconQueued.retired: rgba(204, 167, 0, 0.7); + --vscode-testing-iconSkipped: #848484; + --vscode-testing-iconSkipped.retired: rgba(132, 132, 132, 0.7); + --vscode-testing-iconUnset: #848484; + --vscode-testing-iconUnset.retired: rgba(132, 132, 132, 0.7); + --vscode-testing-message.error.badgeBackground: #e51400; + --vscode-testing-message.error.badgeBorder: #e51400; + --vscode-testing-message.error.badgeForeground: #ffffff; + --vscode-testing-message.info.decorationForeground: rgba(59, 59, 59, 0.5); + --vscode-testing-messagePeekBorder: #1a85ff; + --vscode-testing-messagePeekHeaderBackground: rgba(26, 133, 255, 0.1); + --vscode-testing-peekBorder: #e51400; + --vscode-testing-peekHeaderBackground: rgba(229, 20, 0, 0.1); + --vscode-testing-runAction: #73c991; + --vscode-testing-uncoveredBackground: rgba(255, 0, 0, 0.2); + --vscode-testing-uncoveredBorder: rgba(255, 0, 0, 0.15); + --vscode-testing-uncoveredBranchBackground: #ff9999; + --vscode-testing-uncoveredGutterBackground: rgba(255, 0, 0, 0.3); + --vscode-textBlockQuote-background: #f8f8f8; + --vscode-textBlockQuote-border: #e5e5e5; + --vscode-textCodeBlock-background: #f8f8f8; + --vscode-textLink-activeForeground: #005fb8; + --vscode-textLink-foreground: #005fb8; + --vscode-textPreformat-background: rgba(0, 0, 0, 0.12); + --vscode-textPreformat-foreground: #3b3b3b; + --vscode-textSeparator-foreground: #21262d; + --vscode-titleBar-activeBackground: #f8f8f8; + --vscode-titleBar-activeForeground: #1e1e1e; + --vscode-titleBar-border: #e5e5e5; + --vscode-titleBar-inactiveBackground: #f8f8f8; + --vscode-titleBar-inactiveForeground: #8b949e; + --vscode-toolbar-activeBackground: rgba(166, 166, 166, 0.31); + --vscode-toolbar-hoverBackground: rgba(184, 184, 184, 0.31); + --vscode-tree-inactiveIndentGuidesStroke: rgba(169, 169, 169, 0.4); + --vscode-tree-indentGuidesStroke: #a9a9a9; + --vscode-tree-tableColumnsBorder: rgba(97, 97, 97, 0.13); + --vscode-tree-tableOddRowsBackground: rgba(59, 59, 59, 0.04); + --vscode-walkThrough-embeddedEditorBackground: #f4f4f4; + --vscode-walkthrough-stepTitle.foreground: #000000; + --vscode-welcomePage-progress.background: #ffffff; + --vscode-welcomePage-progress.foreground: #005fb8; + --vscode-welcomePage-tileBackground: #f3f3f3; + --vscode-welcomePage-tileBorder: rgba(0, 0, 0, 0.1); + --vscode-welcomePage-tileHoverBackground: #dfdfdf; + --vscode-widget-border: #e5e5e5; + --vscode-widget-shadow: rgba(0, 0, 0, 0.16); +} diff --git a/webview-ui/playwright/vscode-theme-base.css b/webview-ui/playwright/vscode-theme-base.css new file mode 100644 index 0000000000..5226acb1cf --- /dev/null +++ b/webview-ui/playwright/vscode-theme-base.css @@ -0,0 +1,41 @@ +:root, +body { + /* Font settings are environment/user configuration, not theme colors. Keep CT typography stable across runners. */ + --vscode-font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif; + --vscode-editor-font-family: "Droid Sans Mono", "monospace", monospace; +} + +body { + margin: 0; + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); +} + +#root { + padding: 16px; +} + +/* @vscode/webview-ui-toolkit/react renders VSCodeButton as a bare + @@ -1158,11 +1156,10 @@ export const ChatTextArea = forwardRef( "relative inline-flex items-center justify-center", "bg-transparent border-none p-1.5", "rounded-md min-w-[28px] min-h-[28px]", - "opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground", + "opacity-60 text-vscode-descriptionForeground hover:text-vscode-foreground", "transition-all duration-150", - "hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]", + enabledChatControlClassName, "focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder", - "active:bg-[rgba(255,255,255,0.1)]", "cursor-pointer", )}> @@ -1182,12 +1179,10 @@ export const ChatTextArea = forwardRef( "transition-all duration-1000", "cursor-pointer", hasInputContent - ? "opacity-50 hover:opacity-100 delay-750 pointer-events-auto" + ? "opacity-50 delay-750 pointer-events-auto" : "opacity-0 pointer-events-none duration-200 delay-0", - hasInputContent && - "hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]", + hasInputContent && enabledChatControlClassName, "focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder", - hasInputContent && "active:bg-[rgba(255,255,255,0.1)]", )}> ( "rounded-md min-w-[28px] min-h-[28px]", "text-vscode-descriptionForeground hover:text-vscode-foreground", "transition-all duration-200", - "opacity-100 hover:opacity-100 pointer-events-auto", - "hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]", + "opacity-100 pointer-events-auto", + enabledChatControlClassName, "focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder", - "active:bg-[rgba(255,255,255,0.1)]", "cursor-pointer", )}> @@ -1244,16 +1238,14 @@ export const ChatTextArea = forwardRef( "text-vscode-descriptionForeground hover:text-vscode-foreground", "transition-all duration-200", isEditMode || isStreaming || hasInputContent - ? "opacity-100 hover:opacity-100 pointer-events-auto" + ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none", (isEditMode || isStreaming || hasInputContent) && - "hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]", + enabledChatControlClassName, "focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder", - (isEditMode || isStreaming || hasInputContent) && - "active:bg-[rgba(255,255,255,0.1)]", (isEditMode || isStreaming || hasInputContent) && "cursor-pointer", isStreaming && - "bg-vscode-button-background hover:bg-vscode-button-background", + "bg-vscode-button-background hover:bg-vscode-button-background active:bg-vscode-button-background", )}> {isStreaming ? ( @@ -1333,9 +1325,8 @@ export const ChatTextArea = forwardRef( "rounded-md min-w-[28px] min-h-[28px]", "text-vscode-foreground opacity-85", "transition-all duration-150", - "hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]", + enabledChatControlClassName, "focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder", - "active:bg-[rgba(255,255,255,0.1)]", "cursor-pointer", )}> diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index b79747eaa5..b6c3b0bdf0 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1,4 +1,13 @@ -import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react" +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react" import { useDeepCompareEffect, useEvent } from "react-use" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import removeMd from "remove-markdown" @@ -55,7 +64,7 @@ export interface ChatViewRef { acceptInput: () => void } -export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit. +import { MAX_IMAGES_PER_MESSAGE } from "./constants" const CHAT_DEFAULT_ITEM_HEIGHT = 180 const CHAT_VIEWPORT_BUFFER = { top: 600, @@ -77,6 +86,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + currentTaskIdRef.current = currentTaskId + }, [currentTaskId]) useEffect(() => { messagesRef.current = messages @@ -514,13 +529,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const newMap = new Map(prev) - newMap.set(message.text!, message.aggregatedCosts!) - return newMap - }) + if (message.text && message.text === currentTaskIdRef.current && message.aggregatedCosts) { + setAggregatedCostsMap(new Map([[message.text, message.aggregatedCosts]])) } break } @@ -1630,6 +1642,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0 - ) - } + aggregatedCost={currentTaskAggregatedCosts?.totalCost} + hasSubtasks={(currentTaskAggregatedCosts?.childrenCost ?? 0) > 0} parentTaskId={currentTaskItem?.parentTaskId} costBreakdown={ - currentTaskItem?.id && aggregatedCostsMap.has(currentTaskItem.id) - ? getCostBreakdownIfNeeded(aggregatedCostsMap.get(currentTaskItem.id)!, { + currentTaskAggregatedCosts + ? getCostBreakdownIfNeeded(currentTaskAggregatedCosts, { own: t("common:costs.own"), subtasks: t("common:costs.subtasks"), }) diff --git a/webview-ui/src/components/chat/CodebaseSearchResult.tsx b/webview-ui/src/components/chat/CodebaseSearchResult.tsx index 8280ea3d47..74249c6f73 100644 --- a/webview-ui/src/components/chat/CodebaseSearchResult.tsx +++ b/webview-ui/src/components/chat/CodebaseSearchResult.tsx @@ -30,15 +30,15 @@ const CodebaseSearchResult: React.FC = ({ filePath, s
+ className="group p-2 border border-vscode-editorGroup-border cursor-pointer hover:bg-vscode-list-hoverBackground">
- + {filePath.split("/").at(-1)}:{startLine === endLine ? startLine : `${startLine}-${endLine}`} - + {filePath.split("/").slice(0, -1).join("/")} - + {score.toFixed(3)}
diff --git a/webview-ui/src/components/chat/FollowUpSuggest.tsx b/webview-ui/src/components/chat/FollowUpSuggest.tsx index 42b41bacfa..b9e1c9eecd 100644 --- a/webview-ui/src/components/chat/FollowUpSuggest.tsx +++ b/webview-ui/src/components/chat/FollowUpSuggest.tsx @@ -36,14 +36,15 @@ export const FollowUpSuggest = ({ // Start countdown timer when auto-approval is enabled for follow-up questions useEffect(() => { // Only start countdown if auto-approval is enabled for follow-up questions and no suggestion has been selected - // Also stop countdown if the question has been answered or auto-approval is paused (user is typing) + // Also stop countdown if the question has been answered or auto-approval is paused (user is typing) or timer is disabled (set to 0) if ( autoApprovalEnabled && alwaysAllowFollowupQuestions && suggestions.length > 0 && !suggestionSelected && !isAnswered && - !isFollowUpAutoApprovalPaused + !isFollowUpAutoApprovalPaused && + (followupAutoApproveTimeoutMs ?? DEFAULT_FOLLOWUP_TIMEOUT_MS) > 0 ) { // Start with the configured timeout in seconds const timeoutMs = diff --git a/webview-ui/src/components/chat/IconButton.tsx b/webview-ui/src/components/chat/IconButton.tsx index 00210ac5b3..d2acd53b00 100644 --- a/webview-ui/src/components/chat/IconButton.tsx +++ b/webview-ui/src/components/chat/IconButton.tsx @@ -1,5 +1,6 @@ import { cn } from "@src/lib/utils" import { Button, StandardTooltip } from "@src/components/ui" +import { disabledChatControlClassName, enabledChatControlClassName } from "./chatControlStyles" interface IconButtonProps extends React.ButtonHTMLAttributes { iconClass: string @@ -30,12 +31,9 @@ export const IconButton: React.FC = ({ "rounded-md min-w-[28px] min-h-[28px]", "text-vscode-foreground opacity-85", "transition-all duration-150", - "hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]", "focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder", - "active:bg-[rgba(255,255,255,0.1)]", - !disabled && "cursor-pointer", - disabled && - "opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent", + !disabled && cn("cursor-pointer", enabledChatControlClassName), + disabled && disabledChatControlClassName, className, )} disabled={disabled} diff --git a/webview-ui/src/components/chat/IndexingStatusBadge.tsx b/webview-ui/src/components/chat/IndexingStatusBadge.tsx index 227df3e645..22dbe8f9d5 100644 --- a/webview-ui/src/components/chat/IndexingStatusBadge.tsx +++ b/webview-ui/src/components/chat/IndexingStatusBadge.tsx @@ -11,6 +11,7 @@ import { useExtensionState } from "@src/context/ExtensionStateContext" import { PopoverTrigger, StandardTooltip, Button } from "@src/components/ui" import { CodeIndexPopover } from "./CodeIndexPopover" +import { enabledChatControlClassName } from "./chatControlStyles" interface IndexingStatusBadgeProps { className?: string @@ -96,7 +97,7 @@ export const IndexingStatusBadge: React.FC = ({ classN className={cn( "relative h-5 w-5 p-0", "text-vscode-foreground opacity-85", - "hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)]", + enabledChatControlClassName, "focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder", className, )}> diff --git a/webview-ui/src/components/chat/LucideIconButton.tsx b/webview-ui/src/components/chat/LucideIconButton.tsx index a527df3f1d..4f53b1248b 100644 --- a/webview-ui/src/components/chat/LucideIconButton.tsx +++ b/webview-ui/src/components/chat/LucideIconButton.tsx @@ -2,6 +2,7 @@ import { forwardRef } from "react" import { cn } from "@src/lib/utils" import { Button, StandardTooltip } from "@src/components/ui" import { Loader2, LucideIcon } from "lucide-react" +import { disabledChatControlClassName, enabledChatControlClassName } from "./chatControlStyles" interface LucideIconButtonProps extends React.ButtonHTMLAttributes { icon: LucideIcon @@ -27,9 +28,8 @@ export const LucideIconButton = forwardRef Math.random().toString(36).slice(2, 10) @@ -336,7 +333,7 @@ const UpdateTodoListToolBlock: React.FC = ({ style={{ border: "none", background: "transparent", - color: "#f14c4c", + color: "var(--vscode-errorForeground)", cursor: "pointer", fontSize: 14, marginLeft: 2, @@ -371,7 +368,7 @@ const UpdateTodoListToolBlock: React.FC = ({ fontSize: 13, marginRight: 6, padding: "1px 3px", - borderBottom: "1px solid #eee", + borderBottom: "1px solid var(--vscode-input-border)", }} />
- {/* Delete confirmation dialog */} - {deleteId && ( -
-
e.stopPropagation()}> -
- Are you sure you want to delete this todo item? -
-
- - -
-
-
- )} + + + Delete todo item + Are you sure you want to delete this todo item? + + + Cancel + + + Delete + + + + ) } diff --git a/webview-ui/src/components/chat/WorktreeSelector.tsx b/webview-ui/src/components/chat/WorktreeSelector.tsx index 938fa2cec7..0908516d32 100644 --- a/webview-ui/src/components/chat/WorktreeSelector.tsx +++ b/webview-ui/src/components/chat/WorktreeSelector.tsx @@ -11,6 +11,7 @@ import { vscode } from "@/utils/vscode" import { CreateWorktreeModal } from "../worktrees/CreateWorktreeModal" import { IconButton } from "./IconButton" +import { enabledChatControlClassName } from "./chatControlStyles" interface WorktreeSelectorProps { disabled?: boolean @@ -95,7 +96,7 @@ export const WorktreeSelector = ({ disabled = false }: WorktreeSelectorProps) => "transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset", disabled ? "opacity-50 cursor-not-allowed" - : "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer", + : cn("opacity-90 cursor-pointer", enabledChatControlClassName), )}> {t("worktrees:selector.worktree")}: diff --git a/webview-ui/src/components/chat/__tests__/Announcement.spec.tsx b/webview-ui/src/components/chat/__tests__/Announcement.spec.tsx index b99ff02ea1..170f2e8b6a 100644 --- a/webview-ui/src/components/chat/__tests__/Announcement.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/Announcement.spec.tsx @@ -13,7 +13,7 @@ vi.mock("@src/utils/vscode", () => ({ vi.mock("@roo/package", () => ({ Package: { - version: "3.74.0", + version: "3.78.0", }, })) @@ -41,11 +41,11 @@ vi.mock("@src/i18n/TranslationContext", () => ({ const translations: Record = { "chat:announcement.release.heading": "What's New:", "chat:announcement.release.highlight1": - "More OpenAI controls — use Fast priority mode with OpenAI Codex and choose higher reasoning effort for OpenAI-compatible models.", + "Three major new models have arrived — use the brand-new Gemini 3.7 Flash, GLM 5.3, and Qwen3.8 Max models, plus updated DeepSeek V4 reasoning, pricing, and provider coverage.", "chat:announcement.release.highlight2": - "More reliable providers and models — improved router metadata handling, Ollama model refresh, Bedrock proxy support, and Friendli reasoning controls.", + "Connect to NanoGPT — use dynamic model discovery, streaming and prompt completions, and routing preferences for speed, price, latency, throughput, tool support, and caching.", "chat:announcement.release.highlight3": - "Smoother settings and developer workflows — settings now preserve unsaved edits, short terminal commands complete cleanly, architect plans use workspace-relative paths, and remaining user-facing Roo branding is updated to Zoo.", + "More reliable providers and tasks — fixes improve Azure OpenAI endpoint setup, Kimi Code output limits, task-history title preservation, and Zoo settings import/export.", "chat:announcement.handoff.heading": "The Roo Code plugin is not going away.", } @@ -62,20 +62,20 @@ describe("Announcement", () => { it("renders the announcement title and highlights", () => { render() - expect(screen.getByText("Zoo Code 3.74.0 Released")).toBeInTheDocument() + expect(screen.getByText("Zoo Code 3.78.0 Released")).toBeInTheDocument() expect( screen.getByText( - "More OpenAI controls — use Fast priority mode with OpenAI Codex and choose higher reasoning effort for OpenAI-compatible models.", + "Three major new models have arrived — use the brand-new Gemini 3.7 Flash, GLM 5.3, and Qwen3.8 Max models, plus updated DeepSeek V4 reasoning, pricing, and provider coverage.", ), ).toBeInTheDocument() expect( screen.getByText( - "More reliable providers and models — improved router metadata handling, Ollama model refresh, Bedrock proxy support, and Friendli reasoning controls.", + "Connect to NanoGPT — use dynamic model discovery, streaming and prompt completions, and routing preferences for speed, price, latency, throughput, tool support, and caching.", ), ).toBeInTheDocument() expect( screen.getByText( - "Smoother settings and developer workflows — settings now preserve unsaved edits, short terminal commands complete cleanly, architect plans use workspace-relative paths, and remaining user-facing Roo branding is updated to Zoo.", + "More reliable providers and tasks — fixes improve Azure OpenAI endpoint setup, Kimi Code output limits, task-history title preservation, and Zoo settings import/export.", ), ).toBeInTheDocument() }) diff --git a/webview-ui/src/components/chat/__tests__/AutoApproveDropdown.spec.tsx b/webview-ui/src/components/chat/__tests__/AutoApproveDropdown.spec.tsx new file mode 100644 index 0000000000..113a088138 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/AutoApproveDropdown.spec.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@/utils/test-utils" +import { describe, expect, test, vi } from "vitest" + +import { AutoApproveDropdown } from "../AutoApproveDropdown" + +vi.mock("@/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + autoApprovalEnabled: false, + setAutoApprovalEnabled: vi.fn(), + setAlwaysAllowReadOnly: vi.fn(), + setAlwaysAllowWrite: vi.fn(), + setAlwaysAllowExecute: vi.fn(), + setAlwaysAllowMcp: vi.fn(), + setAlwaysAllowModeSwitch: vi.fn(), + setAlwaysAllowSubtasks: vi.fn(), + setAlwaysAllowFollowupQuestions: vi.fn(), + }), +})) + +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@/hooks/useAutoApprovalToggles", () => ({ + useAutoApprovalToggles: () => ({ + alwaysAllowReadOnly: false, + alwaysAllowWrite: false, + alwaysAllowExecute: false, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowFollowupQuestions: false, + }), +})) + +vi.mock("@/hooks/useAutoApprovalState", () => ({ + useAutoApprovalState: () => ({ effectiveAutoApprovalEnabled: false }), +})) + +vi.mock("@/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: () => document.body, +})) + +describe("AutoApproveDropdown", () => { + test("enables the trigger by default", () => { + render() + + expect(screen.getByTestId("auto-approve-dropdown-trigger")).toBeEnabled() + }) + + test("disables the trigger when auto-approval controls are unavailable", () => { + render() + + expect(screen.getByTestId("auto-approve-dropdown-trigger")).toBeDisabled() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatControlButtons.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatControlButtons.spec.tsx new file mode 100644 index 0000000000..e22dca8984 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatControlButtons.spec.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen } from "@/utils/test-utils" +import { CircleHelp } from "lucide-react" +import { describe, expect, test, vi } from "vitest" + +import { IconButton } from "../IconButton" +import { LucideIconButton } from "../LucideIconButton" + +describe("chat control buttons", () => { + test("invokes enabled codicon controls", () => { + const onClick = vi.fn() + render() + + fireEvent.click(screen.getByRole("button", { name: "Settings" })) + expect(onClick).toHaveBeenCalledOnce() + }) + + test("keeps disabled codicon controls inert", () => { + const onClick = vi.fn() + render( + , + ) + + const button = screen.getByRole("button", { name: "Settings" }) + expect(button).toBeDisabled() + fireEvent.click(button) + expect(onClick).not.toHaveBeenCalled() + }) + + test("renders enabled and disabled Lucide controls", () => { + const { rerender } = render() + expect(screen.getByRole("button", { name: "Help" })).toBeEnabled() + + rerender() + expect(screen.getByRole("button", { name: "Help" })).toBeDisabled() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx new file mode 100644 index 0000000000..4374eaea51 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx @@ -0,0 +1,35 @@ +import React, { useState } from "react" + +import { defaultModeSlug, type Mode } from "@roo/modes" +import { AppProviders } from "../../../../playwright/AppProviders" +import { ChatTextArea } from "../ChatTextArea" + +export function ChatTextAreaStory() { + const [inputValue, setInputValue] = useState("Audit contrast across the Zoo Code webview") + const [selectedImages, setSelectedImages] = useState([]) + const [mode, setMode] = useState(defaultModeSlug) + + return ( + +
+ undefined} + onSelectImages={() => undefined} + shouldDisableImages={false} + mode={mode} + setMode={setMode} + modeShortcutText="Ctrl+. for next mode" + /> +
+
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx new file mode 100644 index 0000000000..a18bcfe817 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx @@ -0,0 +1,29 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" +import { ChatTextAreaStory } from "./ChatTextArea.visual.fixture" + +for (const theme of visualThemes) { + test(`renders the production chat composer in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + await applyVisualTheme(page, theme) + // The full provider bundle leaves a bare Zod reference after CT tree-shaking. + await page.evaluate(() => Object.assign(globalThis, { z: undefined })) + const component = await mount() + const story = component.getByTestId("chat-text-area-story") + const editor = story.getByRole("textbox") + await expect(editor).toBeVisible() + await expect(story).toHaveScreenshot(`chat-composer-resting-${theme.name}.png`) + + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) + for ( + let index = 0; + index < 10 && !(await editor.evaluate((element) => element === document.activeElement)); + index++ + ) { + await page.keyboard.press("Tab") + } + await expect(editor).toBeFocused() + await expect(story).toHaveScreenshot(`chat-composer-focus-${theme.name}.png`) + }) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx index 774e219193..bdbd202830 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -89,6 +89,9 @@ vi.mock("./WorktreeSelector", () => ({ WorktreeSelector: () => null })) vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeLink: ({ children }: { children: React.ReactNode }) => <>{children}, + VSCodeButton: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), })) vi.mock("@/components/ui", async (importOriginal) => { diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 123e0014f3..6b2fa177c9 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -82,6 +82,25 @@ vi.mock("../ChatRow", () => ({ }, })) +const mockTaskHeaderState = vi.hoisted(() => ({ + renders: [] as Array<{ taskId?: string; aggregatedCost?: number }>, +})) + +vi.mock("../TaskHeader", () => ({ + default: function MockTaskHeader({ task, aggregatedCost }: { task: ClineMessage; aggregatedCost?: number }) { + mockTaskHeaderState.renders.push({ taskId: task.text, aggregatedCost }) + + return ( +
+ ) + }, +})) + vi.mock("../AutoApproveMenu", () => ({ default: () => null, })) @@ -339,6 +358,51 @@ const mockPostMessage = (state: Record) => { ) } +const dispatchExtensionMessage = async (data: Record) => { + await act(async () => { + window.dispatchEvent(new MessageEvent("message", { data })) + }) +} + +const dispatchTaskState = async (id: string, taskTs: number, childIds: string[] = []) => { + await dispatchExtensionMessage({ + type: "state", + state: makeExtensionState({ + clineMessages: [ + { + type: "say", + say: "task", + ts: taskTs, + text: id, + }, + ], + currentTaskId: id, + currentTaskItem: { + id, + number: 1, + ts: taskTs, + task: id, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + childIds, + }, + }), + }) +} + +const dispatchAggregatedCosts = async (taskId: string, totalCost: number) => { + await dispatchExtensionMessage({ + type: "taskWithAggregatedCosts", + text: taskId, + aggregatedCosts: { + totalCost, + ownCost: 1, + childrenCost: totalCost - 1, + }, + }) +} + const defaultProps: ChatViewProps = { isHidden: false, showAnnouncement: false, @@ -398,6 +462,65 @@ describe("ChatView - Tool Batching Tests", () => { }) }) +describe("ChatView - Aggregated Costs Lifecycle", () => { + beforeEach(() => { + vi.clearAllMocks() + mockTaskHeaderState.renders.length = 0 + }) + + it("clears cached aggregated costs when switching tasks", async () => { + const { getByTestId } = renderChatView() + + await dispatchTaskState("task-a", 1_000, ["child-a"]) + await dispatchAggregatedCosts("task-a", 9) + + await waitFor(() => { + expect(getByTestId("task-header")).toHaveAttribute("data-aggregated-cost", "9") + }) + + // Use the same message timestamp to prove task identity, rather than task.ts, + // drives the reset. + await dispatchTaskState("task-b", 1_000) + await waitFor(() => { + expect(getByTestId("task-header")).toHaveAttribute("data-task-id", "task-b") + expect(getByTestId("task-header")).toHaveAttribute("data-aggregated-cost", "") + }) + + await dispatchTaskState("task-a", 1_000, ["child-a"]) + await waitFor(() => { + expect(getByTestId("task-header")).toHaveAttribute("data-task-id", "task-a") + expect(getByTestId("task-header")).toHaveAttribute("data-aggregated-cost", "") + }) + }) + + it("rejects a delayed aggregated-cost response from the previous task", async () => { + const { getByTestId } = renderChatView() + + await dispatchTaskState("task-a", 1_001, ["child-a"]) + await dispatchTaskState("task-b", 2_001) + await dispatchAggregatedCosts("task-a", 13) + + await waitFor(() => { + expect(getByTestId("task-header")).toHaveAttribute("data-task-id", "task-b") + expect(getByTestId("task-header")).toHaveAttribute("data-aggregated-cost", "") + }) + + mockTaskHeaderState.renders.length = 0 + await dispatchTaskState("task-a", 1_001, ["child-a"]) + + await waitFor(() => { + expect(getByTestId("task-header")).toHaveAttribute("data-task-id", "task-a") + expect(getByTestId("task-header")).toHaveAttribute("data-aggregated-cost", "") + }) + + expect( + mockTaskHeaderState.renders.some( + ({ taskId, aggregatedCost }) => taskId === "task-a" && aggregatedCost === 13, + ), + ).toBe(false) + }) +}) + describe("ChatView - Sound Playing Tests", () => { beforeEach(() => vi.clearAllMocks()) diff --git a/webview-ui/src/components/chat/__tests__/CodebaseSearchResult.spec.tsx b/webview-ui/src/components/chat/__tests__/CodebaseSearchResult.spec.tsx new file mode 100644 index 0000000000..3cebafa598 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CodebaseSearchResult.spec.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from "@/utils/test-utils" +import { beforeEach, describe, expect, test, vi } from "vitest" + +import CodebaseSearchResult from "../CodebaseSearchResult" +import { vscode } from "@/utils/vscode" + +vi.mock("@/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +describe("CodebaseSearchResult", () => { + beforeEach(() => vi.clearAllMocks()) + + test("opens the selected file at the result start line", () => { + render( + , + ) + + const fileName = screen.getByText("example.ts:12-18") + expect(fileName).toHaveClass("group-hover:text-vscode-list-hoverForeground") + expect(screen.getByText("src")).toHaveClass("group-hover:text-vscode-list-hoverForeground") + fireEvent.click(fileName) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "openFile", + text: "./src/example.ts", + values: { line: 12 }, + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx index a46df75b80..e6d623b389 100644 --- a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext } from "react" -import { render, screen, act } from "@testing-library/react" +import { render, screen, act, fireEvent } from "@testing-library/react" import { TooltipProvider } from "@radix-ui/react-tooltip" import { FollowUpSuggest } from "../FollowUpSuggest" @@ -28,7 +28,7 @@ vi.mock("@src/i18n/TranslationContext", () => ({ interface TestExtensionState { autoApprovalEnabled: boolean alwaysAllowFollowupQuestions: boolean - followupAutoApproveTimeoutMs: number + followupAutoApproveTimeoutMs?: number } const TestExtensionStateContext = createContext(undefined) @@ -74,6 +74,13 @@ describe("FollowUpSuggest", () => { followupAutoApproveTimeoutMs: 3000, // 3 seconds for testing } + // Test state with timeout disabled (0) + const disabledTimeoutState: TestExtensionState = { + autoApprovalEnabled: true, + alwaysAllowFollowupQuestions: true, + followupAutoApproveTimeoutMs: 0, // Disabled + } + beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() @@ -218,6 +225,41 @@ describe("FollowUpSuggest", () => { expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() }) + // Should not show countdown when timeout is disabled (set to 0) + it("should not show countdown when timeout is disabled (set to 0)", () => { + renderWithTestProviders( + , + disabledTimeoutState, + ) + + // Should not show countdown when timeout is disabled + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + }) + + it("should not show countdown when timeout is negative", () => { + const negativeTimeoutState: TestExtensionState = { + ...defaultTestState, + followupAutoApproveTimeoutMs: -1000, + } + + renderWithTestProviders( + , + negativeTimeoutState, + ) + + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + }) + it("should not render when no suggestions are provided", () => { const { container } = renderWithTestProviders( { expect(mockOnCancelAutoApproval).toHaveBeenCalled() }) }) + + describe("suggestion interactions", () => { + it("cancels countdown and forwards click when user clicks a suggestion", () => { + renderWithTestProviders( + , + defaultTestState, + ) + + fireEvent.click(screen.getByText("First suggestion")) + + expect(mockOnSuggestionClick).toHaveBeenCalledWith( + expect.objectContaining({ answer: "First suggestion" }), + expect.objectContaining({ shiftKey: false }), + ) + expect(mockOnCancelAutoApproval).toHaveBeenCalled() + expect(screen.queryByText(/Selecting in \d+s/)).not.toBeInTheDocument() + }) + + it("keeps countdown when shift-clicking a suggestion", () => { + renderWithTestProviders( + , + defaultTestState, + ) + + mockOnCancelAutoApproval.mockClear() + fireEvent.click(screen.getByText("First suggestion"), { shiftKey: true }) + + expect(mockOnSuggestionClick).toHaveBeenCalledWith( + expect.objectContaining({ answer: "First suggestion" }), + expect.objectContaining({ shiftKey: true }), + ) + expect(mockOnCancelAutoApproval).not.toHaveBeenCalled() + expect(screen.getByText(/Selecting in 3s/)).toBeInTheDocument() + }) + + it("copies suggestion into input when the copy affordance is clicked", () => { + const { container } = renderWithTestProviders( + , + defaultTestState, + ) + + const copyAffordance = container.querySelector( + ".absolute.cursor-pointer.top-1\\.5.right-1\\.5", + ) as HTMLElement + + expect(copyAffordance).toBeTruthy() + fireEvent.click(copyAffordance) + + expect(mockOnSuggestionClick).toHaveBeenCalledWith( + expect.objectContaining({ answer: "First suggestion" }), + expect.objectContaining({ shiftKey: true }), + ) + expect(mockOnCancelAutoApproval).toHaveBeenCalled() + expect(screen.queryByText(/Selecting in \d+s/)).not.toBeInTheDocument() + }) + + it("uses default timeout when extension state timeout is undefined", () => { + const stateWithUndefinedTimeout = { + ...defaultTestState, + followupAutoApproveTimeoutMs: undefined, + } + + renderWithTestProviders( + , + stateWithUndefinedTimeout, + ) + + expect(screen.getByText(/Selecting in 60s/)).toBeInTheDocument() + }) + }) }) diff --git a/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx index 6393021e62..ac8c324efd 100644 --- a/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx @@ -76,6 +76,20 @@ describe("ModeSelector", () => { expect(screen.getByTestId("mode-selector-trigger")).toBeInTheDocument() }) + test("disables the trigger when mode selection is unavailable", () => { + render( + , + ) + + expect(screen.getByTestId("mode-selector-trigger")).toBeDisabled() + }) + test("shows search bar when there are more than 6 modes", () => { mockModes = Array.from({ length: 7 }, (_, i) => ({ slug: `mode-${i}`, diff --git a/webview-ui/src/components/chat/__tests__/ThemeAwareControls.visual.tsx b/webview-ui/src/components/chat/__tests__/ThemeAwareControls.visual.tsx new file mode 100644 index 0000000000..49d05e183a --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ThemeAwareControls.visual.tsx @@ -0,0 +1,90 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import UpdateTodoListToolBlock from "../UpdateTodoListToolBlock" +import { SelectDropdown } from "@/components/ui/select-dropdown" + +const themes = [ + { + name: "dark", + bodyClass: "vscode-dark", + themeId: "Default Dark Modern", + expected: { + background: "rgb(31, 31, 31)", + description: "rgb(157, 157, 157)", + dropdownBorder: "rgb(60, 60, 60)", + hoverBackground: "rgba(90, 93, 94, 0.31)", + focusBorder: "rgb(0, 120, 212)", + error: "color(srgb 0.912157 0.486471 0.466078)", + panelBorder: "rgb(43, 43, 43)", + }, + }, + { + name: "light", + bodyClass: "vscode-light", + themeId: "Default Light Modern", + expected: { + background: "rgb(255, 255, 255)", + description: "rgb(59, 59, 59)", + dropdownBorder: "rgb(206, 206, 206)", + hoverBackground: "rgba(184, 184, 184, 0.31)", + focusBorder: "rgb(0, 95, 184)", + error: "color(srgb 0.713137 0.287451 0.267059)", + panelBorder: "rgb(229, 229, 229)", + }, + }, +] as const + +for (const theme of themes) { + test(`renders selectors and confirmation dialogs in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + await page.evaluate(({ bodyClass, themeId }) => { + document.documentElement.className = bodyClass + document.body.className = bodyClass + document.body.dataset.vscodeThemeId = themeId + }, theme) + + const component = await mount( +
+ undefined} /> + undefined} + /> +
, + ) + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot(`chat-controls-resting-${theme.name}.png`) + + const trigger = component.getByTestId("dropdown-trigger") + await expect(trigger).toHaveCSS("border-color", theme.expected.dropdownBorder) + await trigger.hover() + await expect(trigger).toHaveCSS("background-color", theme.expected.hoverBackground) + await expect(trigger).toHaveCSS("border-color", theme.expected.focusBorder) + await page.keyboard.press("Tab") + await expect(trigger).toBeFocused() + await expect + .poll(() => trigger.evaluate((element) => getComputedStyle(element).boxShadow)) + .toContain(theme.expected.focusBorder) + + await expect(component).toHaveScreenshot(`chat-controls-focus-${theme.name}.png`) + + await component.getByRole("button", { name: "Edit" }).click() + await component.getByTitle("Remove").click() + const dialog = page.getByRole("alertdialog") + await expect(dialog).toBeVisible() + await expect(dialog).toHaveCSS("background-color", theme.expected.background) + await expect(dialog).toHaveCSS("border-color", theme.expected.panelBorder) + await expect(page.getByText("Are you sure you want to delete this todo item?")).toHaveCSS( + "color", + theme.expected.description, + ) + await expect(page.getByRole("button", { name: "Delete" })).toHaveCSS("color", theme.expected.error) + + await expect(dialog).toHaveScreenshot(`chat-controls-delete-dialog-${theme.name}.png`) + }) +} diff --git a/webview-ui/src/components/chat/__tests__/ThemeTokenCleanup.visual.tsx b/webview-ui/src/components/chat/__tests__/ThemeTokenCleanup.visual.tsx new file mode 100644 index 0000000000..2d10c1242b --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ThemeTokenCleanup.visual.tsx @@ -0,0 +1,70 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { Checkbox } from "@/components/ui/checkbox" +import { enabledChatControlClassName } from "../chatControlStyles" + +const themes = [ + { + name: "dark", + bodyClass: "vscode-dark", + themeId: "Default Dark Modern", + expected: { + hover: "rgba(90, 93, 94, 0.31)", + active: "rgb(42, 45, 46)", + description: "rgb(157, 157, 157)", + background: "rgb(31, 31, 31)", + }, + }, + { + name: "light", + bodyClass: "vscode-light", + themeId: "Default Light Modern", + expected: { + hover: "rgba(184, 184, 184, 0.31)", + active: "rgb(242, 242, 242)", + description: "rgb(59, 59, 59)", + background: "rgb(255, 255, 255)", + }, + }, +] as const + +for (const theme of themes) { + test(`renders remaining controls in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + await page.evaluate(({ bodyClass, themeId }) => { + document.documentElement.className = bodyClass + document.body.className = bodyClass + document.body.dataset.vscodeThemeId = themeId + }, theme) + + const component = await mount( +
+ + +
, + ) + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot(`remaining-controls-resting-${theme.name}.png`) + + const iconButton = component.getByRole("button", { name: "Settings" }) + await iconButton.hover() + await expect(iconButton).toHaveCSS("background-color", theme.expected.hover) + await iconButton.focus() + await page.mouse.down() + await expect(iconButton).toHaveCSS("background-color", theme.expected.active) + await page.mouse.up() + + await expect(component).toHaveScreenshot(`remaining-controls-active-${theme.name}.png`) + + const checkbox = component.getByRole("checkbox", { name: "Include optional context" }) + await expect(checkbox).toHaveCSS("background-color", theme.expected.description) + await expect(checkbox).toHaveCSS("color", theme.expected.background) + }) +} diff --git a/webview-ui/src/components/chat/__tests__/UpdateTodoListToolBlock.spec.tsx b/webview-ui/src/components/chat/__tests__/UpdateTodoListToolBlock.spec.tsx new file mode 100644 index 0000000000..b9397abca0 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/UpdateTodoListToolBlock.spec.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, test, vi } from "vitest" + +import UpdateTodoListToolBlock from "../UpdateTodoListToolBlock" + +describe("UpdateTodoListToolBlock", () => { + const onChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + function renderEditableList() { + return render( + , + ) + } + + it("cancels deletion without changing the todo list", async () => { + renderEditableList() + fireEvent.click(screen.getByRole("button", { name: "Edit" })) + fireEvent.click(screen.getByTitle("Remove")) + + expect(screen.getByRole("alertdialog")).toBeInTheDocument() + expect(screen.getByText("Are you sure you want to delete this todo item?")).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Cancel" })) + + await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument()) + expect(onChange).not.toHaveBeenCalled() + }) + + it("deletes the selected todo after confirmation", async () => { + renderEditableList() + fireEvent.click(screen.getByRole("button", { name: "Edit" })) + fireEvent.click(screen.getByTitle("Remove")) + fireEvent.click(screen.getByRole("button", { name: "Delete" })) + + await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument()) + expect(onChange).toHaveBeenCalledWith([]) + }) + + test("renders theme-aware edit controls", () => { + renderEditableList() + fireEvent.click(screen.getByRole("button", { name: "Edit" })) + + expect(screen.getByTitle("Remove")).toBeInTheDocument() + expect(screen.getByDisplayValue("Ship the follow-up")).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "+ Add Todo" })) + expect(screen.getByPlaceholderText("Enter todo item, press Enter to add")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png new file mode 100644 index 0000000000..4de566f736 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png new file mode 100644 index 0000000000..f5c759fad8 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png new file mode 100644 index 0000000000..465ffc0263 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png new file mode 100644 index 0000000000..1f238a4619 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png new file mode 100644 index 0000000000..fad9aa30b9 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png new file mode 100644 index 0000000000..f5c759fad8 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png new file mode 100644 index 0000000000..465ffc0263 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png new file mode 100644 index 0000000000..1f238a4619 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-delete-dialog-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-delete-dialog-dark.png new file mode 100644 index 0000000000..590b295c54 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-delete-dialog-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-delete-dialog-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-delete-dialog-light.png new file mode 100644 index 0000000000..3f698942cb Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-delete-dialog-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-focus-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-focus-dark.png new file mode 100644 index 0000000000..2abe1dda6a Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-focus-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-focus-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-focus-light.png new file mode 100644 index 0000000000..6d880ae4b8 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-focus-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-resting-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-resting-dark.png new file mode 100644 index 0000000000..306478e04b Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-resting-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-resting-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-resting-light.png new file mode 100644 index 0000000000..3399f8c4de Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-controls-resting-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-active-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-active-dark.png new file mode 100644 index 0000000000..0f9e638ee4 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-active-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-active-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-active-light.png new file mode 100644 index 0000000000..b6fb34cdc2 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-active-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-resting-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-resting-dark.png new file mode 100644 index 0000000000..bcd95fbe47 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-resting-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-resting-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-resting-light.png new file mode 100644 index 0000000000..cbc26ac814 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/remaining-controls-resting-light.png differ diff --git a/webview-ui/src/components/chat/chatControlStyles.ts b/webview-ui/src/components/chat/chatControlStyles.ts new file mode 100644 index 0000000000..a0beb91494 --- /dev/null +++ b/webview-ui/src/components/chat/chatControlStyles.ts @@ -0,0 +1,5 @@ +export const enabledChatControlClassName = + "hover:opacity-100 hover:bg-vscode-toolbar-hoverBackground active:bg-vscode-list-hoverBackground" + +export const disabledChatControlClassName = + "opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent active:bg-transparent" diff --git a/webview-ui/src/components/chat/constants.ts b/webview-ui/src/components/chat/constants.ts new file mode 100644 index 0000000000..83edefdb7e --- /dev/null +++ b/webview-ui/src/components/chat/constants.ts @@ -0,0 +1 @@ +export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit. diff --git a/webview-ui/src/components/common/MermaidBlock.tsx b/webview-ui/src/components/common/MermaidBlock.tsx index 111919c649..346fe3c471 100644 --- a/webview-ui/src/components/common/MermaidBlock.tsx +++ b/webview-ui/src/components/common/MermaidBlock.tsx @@ -7,82 +7,7 @@ import { useAppTranslation } from "@src/i18n/TranslationContext" import { useCopyToClipboard } from "@src/utils/clipboard" import CodeBlock from "./CodeBlock" import { MermaidButton } from "@/components/common/MermaidButton" - -// Removed previous attempts at static imports for individual diagram types -// as the paths were incorrect for Mermaid v11.4.1 and caused errors. -// The primary strategy will now rely on Vite's bundling configuration. - -const MERMAID_THEME = { - background: "#1e1e1e", // VS Code dark theme background - textColor: "#ffffff", // Main text color - mainBkg: "#2d2d2d", // Background for nodes - nodeBorder: "#888888", // Border color for nodes - lineColor: "#cccccc", // Lines connecting nodes - primaryColor: "#3c3c3c", // Primary color for highlights - primaryTextColor: "#ffffff", // Text in primary colored elements - primaryBorderColor: "#888888", - secondaryColor: "#2d2d2d", // Secondary color for alternate elements - tertiaryColor: "#454545", // Third color for special elements - - // Class diagram specific - classText: "#ffffff", - - // State diagram specific - labelColor: "#ffffff", - - // Sequence diagram specific - actorLineColor: "#cccccc", - actorBkg: "#2d2d2d", - actorBorder: "#888888", - actorTextColor: "#ffffff", - - // Flow diagram specific - fillType0: "#2d2d2d", - fillType1: "#3c3c3c", - fillType2: "#454545", -} - -mermaid.initialize({ - startOnLoad: false, - // "strict" is required: mermaid renders LLM-generated source, and looser modes allow HTML injection through diagram labels. - securityLevel: "strict", - theme: "dark", - suppressErrorRendering: true, - themeVariables: { - ...MERMAID_THEME, - fontSize: "16px", - fontFamily: "var(--vscode-font-family, 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif)", - - // Additional styling - noteTextColor: "#ffffff", - noteBkgColor: "#454545", - noteBorderColor: "#888888", - - // Improve contrast for special elements - critBorderColor: "#ff9580", - critBkgColor: "#803d36", - - // Task diagram specific - taskTextColor: "#ffffff", - taskTextOutsideColor: "#ffffff", - taskTextLightColor: "#ffffff", - - // Numbers/sections - sectionBkgColor: "#2d2d2d", - sectionBkgColor2: "#3c3c3c", - - // Alt sections in sequence diagrams - altBackground: "#2d2d2d", - - // Links - linkColor: "#6cb6ff", - - // Borders and lines - compositeBackground: "#2d2d2d", - compositeBorder: "#888888", - titleColor: "#ffffff", - }, -}) +import { getMermaidBackgroundColor, getMermaidConfig, useMermaidTheme } from "./mermaidTheme" interface MermaidBlockProps { code: string @@ -93,22 +18,26 @@ export default function MermaidBlock({ code }: MermaidBlockProps) { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [isErrorExpanded, setIsErrorExpanded] = useState(false) + const renderVersionRef = useRef(0) + const theme = useMermaidTheme() const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard() const { t } = useAppTranslation() - // 1) Whenever `code` changes, mark that we need to re-render a new chart + // Whenever the source or host theme changes, invalidate in-flight rendering. useEffect(() => { + renderVersionRef.current += 1 setIsLoading(true) setError(null) - }, [code]) + }, [code, theme.signature]) - // 2) Debounce the actual parse/render useDebounceEffect( () => { + const renderVersion = renderVersionRef.current if (containerRef.current) { containerRef.current.innerHTML = "" } + mermaid.initialize(getMermaidConfig(theme.kind)) mermaid .parse(code) .then(() => { @@ -116,20 +45,24 @@ export default function MermaidBlock({ code }: MermaidBlockProps) { return mermaid.render(id, code) }) .then(({ svg }) => { - if (containerRef.current) { + if (containerRef.current && renderVersion === renderVersionRef.current) { containerRef.current.innerHTML = svg } }) .catch((err) => { - console.warn("Mermaid parse/render failed:", err) - setError(err.message || "Failed to render Mermaid diagram") + if (renderVersion === renderVersionRef.current) { + console.warn("Mermaid parse/render failed:", err) + setError(err.message || "Failed to render Mermaid diagram") + } }) .finally(() => { - setIsLoading(false) + if (renderVersion === renderVersionRef.current) { + setIsLoading(false) + } }) }, - 500, // Delay 500ms - [code], // Dependencies for scheduling + 500, + [code, theme.signature], ) /** @@ -225,6 +158,7 @@ export default function MermaidBlock({ code }: MermaidBlockProps) { } async function svgToPng(svgEl: SVGElement): Promise { + const backgroundColor = getMermaidBackgroundColor() // Clone the SVG to avoid modifying the original const svgClone = svgEl.cloneNode(true) as SVGElement @@ -266,8 +200,7 @@ async function svgToPng(svgEl: SVGElement): Promise { const ctx = canvas.getContext("2d") if (!ctx) return reject("Canvas context not available") - // Fill background with Mermaid's dark theme background color - ctx.fillStyle = MERMAID_THEME.background + ctx.fillStyle = backgroundColor ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.imageSmoothingEnabled = true diff --git a/webview-ui/src/components/common/TelemetryBanner.tsx b/webview-ui/src/components/common/TelemetryBanner.tsx index 3d39b17115..6ed058d8bf 100644 --- a/webview-ui/src/components/common/TelemetryBanner.tsx +++ b/webview-ui/src/components/common/TelemetryBanner.tsx @@ -1,6 +1,6 @@ -import { memo, useState } from "react" +import { memo } from "react" import { Trans } from "react-i18next" -import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" import type { TelemetrySetting } from "@roo-code/types" @@ -9,13 +9,15 @@ import { useAppTranslation } from "@src/i18n/TranslationContext" const TelemetryBanner = () => { const { t } = useAppTranslation() - const [isDismissed, setIsDismissed] = useState(false) - const handleClose = () => { - setIsDismissed(true) + const handleAccept = () => { vscode.postMessage({ type: "telemetrySetting", text: "enabled" satisfies TelemetrySetting }) } + const handleDecline = () => { + vscode.postMessage({ type: "telemetrySetting", text: "disabled" satisfies TelemetrySetting }) + } + const handleOpenSettings = () => { window.postMessage({ type: "action", @@ -24,22 +26,10 @@ const TelemetryBanner = () => { }) } - if (isDismissed) { - return null - } - return ( -
- {/* Close button (X) */} - - +
{t("welcome:telemetry.helpImprove")}
-
+
{ }} />
+
+ + {t("welcome:telemetry.accept")} + + + {t("welcome:telemetry.decline")} + +
) } diff --git a/webview-ui/src/components/common/__tests__/MermaidBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MermaidBlock.spec.tsx new file mode 100644 index 0000000000..428e3ae306 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/MermaidBlock.spec.tsx @@ -0,0 +1,89 @@ +import { act, render, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import MermaidBlock from "../MermaidBlock" + +const mermaidMocks = vi.hoisted(() => ({ + initialize: vi.fn(), + parse: vi.fn(), + renderDiagram: vi.fn(), +})) + +vi.mock("mermaid", () => ({ + default: { + initialize: mermaidMocks.initialize, + parse: mermaidMocks.parse, + render: mermaidMocks.renderDiagram, + }, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@src/utils/clipboard", () => ({ + useCopyToClipboard: () => ({ showCopyFeedback: false, copyWithFeedback: vi.fn() }), +})) + +vi.mock("@/components/common/MermaidButton", () => ({ + MermaidButton: ({ children }: { children: React.ReactNode }) => children, +})) + +describe("MermaidBlock", () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.className = "vscode-light" + document.body.dataset.vscodeThemeId = "Default Light Modern" + document.body.style.setProperty("--vscode-editor-background", "#ffffff") + document.body.style.setProperty("--vscode-editor-foreground", "#333333") + document.body.style.setProperty("--vscode-input-background", "#f3f3f3") + document.body.style.setProperty("--vscode-input-border", "#717171") + mermaidMocks.parse.mockResolvedValue({ diagramType: "flowchart-v2" }) + mermaidMocks.renderDiagram.mockResolvedValue({ svg: '' }) + }) + + it("rerenders an existing diagram when the host theme changes", async () => { + const { getByTestId } = render() + + await waitFor(() => expect(mermaidMocks.renderDiagram).toHaveBeenCalledTimes(1), { timeout: 1_500 }) + expect(getByTestId("rendered-diagram")).toBeInTheDocument() + expect(mermaidMocks.initialize).toHaveBeenLastCalledWith( + expect.objectContaining({ themeVariables: expect.objectContaining({ darkMode: false }) }), + ) + + act(() => { + document.body.className = "vscode-dark" + document.body.dataset.vscodeThemeId = "Default Dark Modern" + document.body.style.setProperty("--vscode-editor-background", "#1e1e1e") + document.body.style.setProperty("--vscode-editor-foreground", "#d4d4d4") + }) + + await waitFor(() => expect(mermaidMocks.renderDiagram).toHaveBeenCalledTimes(2), { timeout: 1_500 }) + expect(mermaidMocks.initialize).toHaveBeenLastCalledWith( + expect.objectContaining({ themeVariables: expect.objectContaining({ darkMode: true }) }), + ) + }) + + it("does not replace the current theme with a stale render", async () => { + let resolveFirstRender!: (value: { svg: string }) => void + mermaidMocks.renderDiagram + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirstRender = resolve))) + .mockResolvedValueOnce({ svg: '' }) + const { queryByTestId } = render() + + await waitFor(() => expect(mermaidMocks.renderDiagram).toHaveBeenCalledTimes(1), { timeout: 1_500 }) + act(() => { + document.body.className = "vscode-dark" + document.body.dataset.vscodeThemeId = "Default Dark Modern" + document.body.style.setProperty("--vscode-editor-background", "#1e1e1e") + document.body.style.setProperty("--vscode-editor-foreground", "#d4d4d4") + }) + + await waitFor(() => expect(mermaidMocks.renderDiagram).toHaveBeenCalledTimes(2), { timeout: 1_500 }) + await waitFor(() => expect(queryByTestId("dark-diagram")).toBeInTheDocument()) + await act(async () => resolveFirstRender({ svg: '' })) + + expect(queryByTestId("dark-diagram")).toBeInTheDocument() + expect(queryByTestId("stale-light-diagram")).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/common/__tests__/MermaidBlock.visual.tsx b/webview-ui/src/components/common/__tests__/MermaidBlock.visual.tsx new file mode 100644 index 0000000000..561d189229 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/MermaidBlock.visual.tsx @@ -0,0 +1,35 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { expectContrast } from "../../../../playwright/contrast" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" +import MermaidBlock from "../MermaidBlock" + +const diagram = `gantt + title Project plan + dateFormat YYYY-MM-DD + section Planning + Define scope :done, scope, 2026-08-01, 3d + section Delivery + Ship release :active, release, after scope, 3d` + +for (const theme of visualThemes) { + test(`renders Mermaid sections in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + await applyVisualTheme(page, theme) + + const component = await mount() + const svg = component.locator("svg") + await expect(svg).toBeVisible({ timeout: 10_000 }) + await expect(svg.locator("..")).toHaveCSS("opacity", "1") + + await expectContrast(component.locator(".sectionTitle0"), { + background: component.locator(".section0"), + foregroundProperty: "fill", + backgroundProperty: "fill", + minimum: 4.5, + label: `${theme.name} Mermaid section title`, + }) + + await expect(component).toHaveScreenshot(`mermaid-gantt-${theme.name}.png`) + }) +} diff --git a/webview-ui/src/components/common/__tests__/TelemetryBanner.spec.tsx b/webview-ui/src/components/common/__tests__/TelemetryBanner.spec.tsx new file mode 100644 index 0000000000..ddffaff20a --- /dev/null +++ b/webview-ui/src/components/common/__tests__/TelemetryBanner.spec.tsx @@ -0,0 +1,61 @@ +import { render, screen, fireEvent } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" + +import TelemetryBanner from "../TelemetryBanner" + +const mockPostMessage = vi.fn() +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (message: any) => mockPostMessage(message), + }, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "welcome:telemetry.helpImprove": "Help Improve Zoo Code", + "welcome:telemetry.helpImproveMessage": "Zoo Code collects error and usage data...", + "welcome:telemetry.accept": "Accept", + "welcome:telemetry.decline": "Decline", + } + return translations[key] || key + }, + }), +})) + +describe("TelemetryBanner", () => { + beforeEach(() => { + mockPostMessage.mockClear() + }) + + it("renders explicit Accept and Decline actions", () => { + render() + + expect(screen.getByRole("button", { name: "Accept" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Decline" })).toBeInTheDocument() + }) + + it("sends an enabled setting when Accept is clicked", () => { + render() + + fireEvent.click(screen.getByRole("button", { name: "Accept" })) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "telemetrySetting", text: "enabled" }) + }) + + it("sends a disabled setting when Decline is clicked", () => { + render() + + fireEvent.click(screen.getByRole("button", { name: "Decline" })) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "telemetrySetting", text: "disabled" }) + }) + + it("requires an explicit telemetry choice", () => { + render() + + expect(screen.getAllByRole("button").map((button) => button.textContent)).toEqual(["Accept", "Decline"]) + expect(mockPostMessage).not.toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.fixture.tsx b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.fixture.tsx new file mode 100644 index 0000000000..0df2d5b226 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.fixture.tsx @@ -0,0 +1,18 @@ +import React from "react" +import { I18nextProvider } from "react-i18next" + +import { TranslationContext } from "@src/i18n/TranslationContext" +import TelemetryBanner from "../TelemetryBanner" +import { visualTestI18n, visualTestTranslations } from "./TelemetryBanner.visual.i18n" + +export const TelemetryBannerFixture = () => ( + + visualTestTranslations[key] ?? key, + i18n: null as unknown as typeof import("../../../i18n/setup").default, + }}> + + + +) diff --git a/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.i18n.ts b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.i18n.ts new file mode 100644 index 0000000000..95e7a8d1b6 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.i18n.ts @@ -0,0 +1,36 @@ +import i18next from "i18next" +import { initReactI18next } from "react-i18next" + +export const visualTestTranslations: Record = { + "welcome:telemetry.helpImprove": "Help Improve Zoo Code", + "welcome:telemetry.helpImproveMessage": + "Zoo Code collects error and usage data, linked to a per-install identifier, to help us fix bugs and improve the extension. This telemetry does not collect your code or prompts. You can turn this off in settings.", + "welcome:telemetry.accept": "Accept", + "welcome:telemetry.decline": "Decline", +} + +// Trans reads from its own react-i18next instance rather than the useAppTranslation +// context, so it needs a real (if minimal) i18next init to resolve helpImproveMessage +// and the settingsLink interpolation instead of rendering nothing. init() returns a +// promise even for inline resources, so callers must await it before mounting. +export const visualTestI18n = i18next.createInstance() + +export const visualTestI18nReady = visualTestI18n.use(initReactI18next).init({ + lng: "en", + fallbackLng: "en", + ns: ["welcome"], + defaultNS: "welcome", + resources: { + en: { + welcome: { + telemetry: { + helpImprove: visualTestTranslations["welcome:telemetry.helpImprove"], + helpImproveMessage: visualTestTranslations["welcome:telemetry.helpImproveMessage"], + accept: visualTestTranslations["welcome:telemetry.accept"], + decline: visualTestTranslations["welcome:telemetry.decline"], + }, + }, + }, + }, + interpolation: { escapeValue: false }, +}) diff --git a/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.tsx b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.tsx new file mode 100644 index 0000000000..c8889415fe --- /dev/null +++ b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.tsx @@ -0,0 +1,18 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { TelemetryBannerFixture } from "./TelemetryBanner.visual.fixture" +import { visualTestI18nReady } from "./TelemetryBanner.visual.i18n" + +test("renders the telemetry consent banner in the VS Code dark theme", async ({ mount }) => { + await visualTestI18nReady + + const component = await mount() + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot("telemetry-banner-dark.png") +}) diff --git a/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-dark.png b/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-dark.png new file mode 100644 index 0000000000..41a91adb7a Binary files /dev/null and b/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-dark.png differ diff --git a/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-high-contrast-light.png b/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-high-contrast-light.png new file mode 100644 index 0000000000..244c8e7f30 Binary files /dev/null and b/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-high-contrast-light.png differ diff --git a/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-high-contrast.png b/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-high-contrast.png new file mode 100644 index 0000000000..7568444947 Binary files /dev/null and b/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-high-contrast.png differ diff --git a/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-light.png b/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-light.png new file mode 100644 index 0000000000..7413093091 Binary files /dev/null and b/webview-ui/src/components/common/__tests__/__screenshots__/mermaid-gantt-light.png differ diff --git a/webview-ui/src/components/common/__tests__/__screenshots__/telemetry-banner-dark.png b/webview-ui/src/components/common/__tests__/__screenshots__/telemetry-banner-dark.png new file mode 100644 index 0000000000..1295c77846 Binary files /dev/null and b/webview-ui/src/components/common/__tests__/__screenshots__/telemetry-banner-dark.png differ diff --git a/webview-ui/src/components/common/__tests__/contrast.spec.ts b/webview-ui/src/components/common/__tests__/contrast.spec.ts new file mode 100644 index 0000000000..20df6d2cb0 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/contrast.spec.ts @@ -0,0 +1,42 @@ +import { composite, contrastRatio, parseCssColor, requiredTextContrast } from "../../../../playwright/contrast" + +describe("contrast utilities", () => { + it("parses opaque and translucent browser colors", () => { + expect(parseCssColor("rgb(30, 30, 30)")).toEqual({ r: 30, g: 30, b: 30, a: 1 }) + expect(parseCssColor("rgba(255, 255, 255, 0.5)")).toEqual({ r: 255, g: 255, b: 255, a: 0.5 }) + expect(parseCssColor("color(srgb 1 0.5 0 / 25%)")).toEqual({ r: 255, g: 127.5, b: 0, a: 0.25 }) + const oklab = parseCssColor("oklab(1 0 0 / 60%)") + expect(oklab.r).toBeCloseTo(255) + expect(oklab.g).toBeCloseTo(255) + expect(oklab.b).toBeCloseTo(255) + expect(oklab.a).toBe(0.6) + }) + + it("composites translucent colors without rounding", () => { + expect(composite(parseCssColor("rgba(255, 255, 255, 0.5)"), parseCssColor("rgb(0, 0, 0)"))).toEqual({ + r: 127.5, + g: 127.5, + b: 127.5, + a: 1, + }) + }) + + it("calculates WCAG contrast ratios", () => { + expect(contrastRatio(parseCssColor("rgb(255, 255, 255)"), parseCssColor("rgb(0, 0, 0)"))).toBe(21) + expect(contrastRatio(parseCssColor("rgb(119, 119, 119)"), parseCssColor("rgb(255, 255, 255)"))).toBeCloseTo( + 4.48, + 2, + ) + }) + + it("uses exact WCAG large-text thresholds", () => { + expect(requiredTextContrast(24, 400)).toBe(3) + expect(requiredTextContrast(23.99, 400)).toBe(4.5) + expect(requiredTextContrast(56 / 3, 700)).toBe(3) + expect(requiredTextContrast(18.66, 700)).toBe(4.5) + }) + + it("rejects unsupported color formats", () => { + expect(() => parseCssColor("transparent")).toThrow("Unsupported CSS color") + }) +}) diff --git a/webview-ui/src/components/common/__tests__/mermaidTheme.spec.ts b/webview-ui/src/components/common/__tests__/mermaidTheme.spec.ts new file mode 100644 index 0000000000..654efc9deb --- /dev/null +++ b/webview-ui/src/components/common/__tests__/mermaidTheme.spec.ts @@ -0,0 +1,89 @@ +import { act, renderHook, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { getMermaidBackgroundColor, getMermaidConfig, useMermaidTheme } from "../mermaidTheme" + +function applyTheme( + className: string, + colors: { background: string; foreground: string; surface: string; border: string; link: string }, +) { + document.body.className = className + document.body.style.setProperty("--vscode-editor-background", colors.background) + document.body.style.setProperty("--vscode-editor-foreground", colors.foreground) + document.body.style.setProperty("--vscode-input-background", colors.surface) + document.body.style.setProperty("--vscode-input-border", colors.border) + document.body.style.setProperty("--vscode-textLink-foreground", colors.link) +} + +describe("Mermaid theme", () => { + beforeEach(() => { + document.body.dataset.vscodeThemeId = "Default Light Modern" + applyTheme("vscode-light", { + background: "#ffffff", + foreground: "#333333", + surface: "#f3f3f3", + border: "#717171", + link: "#006ab1", + }) + }) + + afterEach(() => { + document.body.className = "" + document.body.removeAttribute("style") + delete document.body.dataset.vscodeThemeId + delete document.body.dataset.vscodeThemeKind + }) + + it("builds a light base theme from VS Code colors", () => { + const config = getMermaidConfig("light") + + expect(config).toMatchObject({ + securityLevel: "strict", + theme: "base", + themeVariables: { + darkMode: false, + background: "#ffffff", + primaryColor: "#f3f3f3", + primaryTextColor: "#333333", + primaryBorderColor: "#717171", + linkColor: "#006ab1", + }, + }) + expect(getMermaidBackgroundColor()).toBe("#ffffff") + }) + + it("builds a dark theme and normalizes translucent colors", () => { + applyTheme("vscode-dark", { + background: "rgb(30, 30, 30)", + foreground: "rgb(212, 212, 212)", + surface: "rgba(60, 60, 60, 0.5)", + border: "#8888", + link: "#3794ff", + }) + + const config = getMermaidConfig("dark") + + expect(config.themeVariables).toMatchObject({ + darkMode: true, + background: "#1e1e1e", + primaryColor: "#2d2d2d", + primaryTextColor: "#d4d4d4", + primaryBorderColor: "#575757", + }) + }) + + it("updates when the host switches themes", async () => { + const { result } = renderHook(() => useMermaidTheme()) + expect(result.current.kind).toBe("light") + + act(() => { + document.body.className = "vscode-high-contrast" + document.body.dataset.vscodeThemeId = "Default High Contrast" + document.body.style.setProperty("--vscode-editor-background", "#000000") + document.body.style.setProperty("--vscode-editor-foreground", "#ffffff") + }) + + await waitFor(() => expect(result.current.kind).toBe("high-contrast")) + expect(result.current.signature).toContain("Default High Contrast") + }) +}) diff --git a/webview-ui/src/components/common/mermaidTheme.ts b/webview-ui/src/components/common/mermaidTheme.ts new file mode 100644 index 0000000000..8f06500e73 --- /dev/null +++ b/webview-ui/src/components/common/mermaidTheme.ts @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react" + +export type MermaidThemeKind = "dark" | "light" | "high-contrast" | "high-contrast-light" + +interface MermaidThemeState { + kind: MermaidThemeKind + signature: string +} + +const DARK_FALLBACK = { + background: "#1e1e1e", + foreground: "#d4d4d4", + surface: "#3c3c3c", + border: "#888888", + link: "#3794ff", +} + +const LIGHT_FALLBACK = { + background: "#ffffff", + foreground: "#333333", + surface: "#ffffff", + border: "#717171", + link: "#006ab1", +} + +function parseColor(value: string): [number, number, number, number] | undefined { + const color = value.trim() + const hex = color.match(/^#([\da-f]{3,8})$/i)?.[1] + + if (hex) { + const expanded = hex.length === 3 || hex.length === 4 ? [...hex].map((digit) => digit + digit).join("") : hex + if (expanded.length === 6 || expanded.length === 8) { + return [ + parseInt(expanded.slice(0, 2), 16), + parseInt(expanded.slice(2, 4), 16), + parseInt(expanded.slice(4, 6), 16), + expanded.length === 8 ? parseInt(expanded.slice(6, 8), 16) / 255 : 1, + ] + } + } + + if (/^rgba?\(/i.test(color)) { + const channels = color.match(/[\d.]+/g)?.map(Number) + if (channels && channels.length >= 3) { + return [channels[0], channels[1], channels[2], channels[3] ?? 1] + } + } + + return undefined +} + +function toHex(value: string, fallback: string, background = fallback): string { + const fallbackColor = parseColor(fallback) ?? [0, 0, 0, 1] + const backgroundColor = parseColor(background) ?? fallbackColor + const [red, green, blue, alpha] = parseColor(value) ?? fallbackColor + const channels = [red, green, blue].map((channel, index) => + Math.round(channel * alpha + backgroundColor[index] * (1 - alpha)), + ) + + return `#${channels.map((channel) => Math.max(0, Math.min(255, channel)).toString(16).padStart(2, "0")).join("")}` +} + +function getThemeKind(): MermaidThemeKind { + const body = document.body + const className = body.className + const themeKind = body.dataset.vscodeThemeKind ?? "" + + if (/vscode-high-contrast-light/i.test(`${className} ${themeKind}`)) return "high-contrast-light" + if (/vscode-high-contrast/i.test(`${className} ${themeKind}`)) return "high-contrast" + if (/vscode-light/i.test(className)) return "light" + return "dark" +} + +function getThemeState(): MermaidThemeState { + const styles = getComputedStyle(document.body) + const signature = [ + document.body.dataset.vscodeThemeId, + document.body.dataset.vscodeThemeKind, + document.body.className, + styles.getPropertyValue("--vscode-editor-background"), + styles.getPropertyValue("--vscode-editor-foreground"), + styles.getPropertyValue("--vscode-input-background"), + styles.getPropertyValue("--vscode-input-border"), + styles.getPropertyValue("--vscode-textLink-foreground"), + ].join("|") + + return { kind: getThemeKind(), signature } +} + +export function useMermaidTheme(): MermaidThemeState { + const [theme, setTheme] = useState(getThemeState) + + useEffect(() => { + const updateTheme = () => { + const nextTheme = getThemeState() + setTheme((currentTheme) => (currentTheme.signature === nextTheme.signature ? currentTheme : nextTheme)) + } + const observer = new MutationObserver(updateTheme) + const options: MutationObserverInit = { + attributes: true, + attributeFilter: ["class", "style", "data-vscode-theme-id", "data-vscode-theme-kind"], + } + + observer.observe(document.documentElement, options) + observer.observe(document.body, options) + return () => observer.disconnect() + }, []) + + return theme +} + +export function getMermaidConfig(kind: MermaidThemeKind) { + const isDark = kind === "dark" || kind === "high-contrast" + const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK + const styles = getComputedStyle(document.body) + const background = toHex(styles.getPropertyValue("--vscode-editor-background"), fallback.background) + const foreground = toHex(styles.getPropertyValue("--vscode-editor-foreground"), fallback.foreground, background) + const surface = toHex(styles.getPropertyValue("--vscode-input-background"), fallback.surface, background) + const border = toHex(styles.getPropertyValue("--vscode-input-border"), fallback.border, background) + const link = toHex(styles.getPropertyValue("--vscode-textLink-foreground"), fallback.link, background) + + return { + startOnLoad: false, + securityLevel: "strict" as const, + theme: "base" as const, + suppressErrorRendering: true, + themeVariables: { + darkMode: isDark, + background, + primaryColor: surface, + primaryTextColor: foreground, + primaryBorderColor: border, + secondaryColor: background, + secondaryTextColor: foreground, + secondaryBorderColor: border, + tertiaryColor: surface, + tertiaryTextColor: foreground, + tertiaryBorderColor: border, + lineColor: foreground, + textColor: foreground, + noteBkgColor: surface, + noteTextColor: foreground, + noteBorderColor: border, + linkColor: link, + fontSize: "16px", + fontFamily: "var(--vscode-font-family, 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif)", + }, + } +} + +export function getMermaidBackgroundColor(): string { + return getMermaidConfig(getThemeKind()).themeVariables.background +} diff --git a/webview-ui/src/components/settings/About.tsx b/webview-ui/src/components/settings/About.tsx index 7459d5b8df..f9075cd558 100644 --- a/webview-ui/src/components/settings/About.tsx +++ b/webview-ui/src/components/settings/About.tsx @@ -4,7 +4,7 @@ import { Trans } from "react-i18next" import { ArrowRightLeft, Download, Upload, TriangleAlert, Bug, Lightbulb, Shield, MessagesSquare } from "lucide-react" import { VSCodeButton, VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" -import type { ExtensionMessage, TelemetrySetting } from "@roo-code/types" +import { type ExtensionMessage, type TelemetrySetting, isTelemetryOptedIn } from "@roo-code/types" import { Package } from "@roo/package" @@ -108,7 +108,7 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug, section="about" label={t("settings:footer.telemetry.label")}> { const checked = e.target.checked === true setTelemetrySetting(checked ? "enabled" : "disabled") diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 0cc61052db..3e1495baff 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -10,6 +10,10 @@ import { isRetiredProvider, providerIdentifiers, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, + OllamaModelsMessageType, + OpenAiModelsMessageType, + RouterModelsMessageType, + VsCodeLmModelsMessageType, } from "@roo-code/types" import { @@ -77,6 +81,7 @@ import { VercelAiGateway, OpenCodeGo, Kenari, + NanoGPT, ZooGateway, MiniMax, Mimo, @@ -213,7 +218,7 @@ const ApiOptions = ({ const headerObject = convertHeadersToObject(customHeaders) vscode.postMessage({ - type: "requestOpenAiModels", + type: OpenAiModelsMessageType.requestOpenAiModels, values: { baseUrl: apiConfiguration?.openAiBaseUrl, apiKey: apiConfiguration?.openAiApiKey, @@ -223,7 +228,7 @@ const ApiOptions = ({ }) } else if (selectedProvider === providerIdentifiers.ollama) { vscode.postMessage({ - type: "requestOllamaModels", + type: OllamaModelsMessageType.requestOllamaModels, values: { baseUrl: apiConfiguration?.ollamaBaseUrl, apiKey: apiConfiguration?.ollamaApiKey, @@ -232,17 +237,17 @@ const ApiOptions = ({ } else if (selectedProvider === providerIdentifiers.lmstudio) { requestLmStudioModels(apiConfiguration?.lmStudioBaseUrl) } else if (selectedProvider === providerIdentifiers.vscodeLm) { - vscode.postMessage({ type: "requestVsCodeLmModels" }) + vscode.postMessage({ type: VsCodeLmModelsMessageType.requestVsCodeLmModels }) } else if (selectedProvider === providerIdentifiers.litellm) { vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { litellmApiKey: apiConfiguration?.litellmApiKey, litellmBaseUrl: apiConfiguration?.litellmBaseUrl, }, }) } else if (selectedProvider === providerIdentifiers.poe) { - vscode.postMessage({ type: "requestRouterModels" }) + vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels }) } }, 250, @@ -677,6 +682,17 @@ const ApiOptions = ({ /> )} + {selectedProvider === providerIdentifiers.nanogpt && ( + + )} + {selectedProvider === providerIdentifiers.zooGateway && (
- {followupAutoApproveTimeoutMs / 1000}s + + {followupAutoApproveTimeoutMs === 0 + ? t("settings:autoApprove.followupQuestions.timeoutDisabled") + : `${followupAutoApproveTimeoutMs / 1000}s`} +
{t("settings:autoApprove.followupQuestions.timeoutLabel")} diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index 64707f6313..aa49528f5e 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -42,6 +42,7 @@ type ModelIdKey = keyof Pick< | "vercelAiGatewayModelId" | "opencodeGoModelId" | "kenariModelId" + | "nanoGptModelId" | "zooGatewayModelId" | "apiModelId" | "ollamaModelId" diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx index 9525ba7a95..d8ee0cd448 100644 --- a/webview-ui/src/components/settings/ThinkingBudget.tsx +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -109,22 +109,27 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod // Clamp to availableOptions so the Select trigger always renders a valid option. const storedReasoningEffort = apiConfiguration.reasoningEffort as ReasoningEffortOption | undefined const rawReasoningEffort: ReasoningEffortOption = storedReasoningEffort || defaultReasoningEffort + const fallbackReasoningEffort = availableOptions.includes(defaultReasoningEffort) + ? defaultReasoningEffort + : (availableOptions[0] ?? rawReasoningEffort) const currentReasoningEffort: ReasoningEffortOption = availableOptions.includes(rawReasoningEffort) ? rawReasoningEffort - : (availableOptions[0] ?? rawReasoningEffort) + : fallbackReasoningEffort // Set default reasoning effort when model supports it and no value is set useEffect(() => { - if (isReasoningEffortSupported && !apiConfiguration.reasoningEffort) { - // Only set a default if reasoning is required, otherwise leave as undefined (which maps to "disable") - if (modelInfo?.requiredReasoningEffort && defaultReasoningEffort !== "disable") { - setApiConfigurationField("reasoningEffort", defaultReasoningEffort as ReasoningEffortExtended, false) - } + if ( + isReasoningEffortSupported && + modelInfo?.requiredReasoningEffort && + storedReasoningEffort !== currentReasoningEffort && + currentReasoningEffort !== "disable" + ) { + setApiConfigurationField("reasoningEffort", currentReasoningEffort as ReasoningEffortExtended, false) } }, [ isReasoningEffortSupported, - apiConfiguration.reasoningEffort, - defaultReasoningEffort, + storedReasoningEffort, + currentReasoningEffort, modelInfo?.requiredReasoningEffort, setApiConfigurationField, ]) diff --git a/webview-ui/src/components/settings/__tests__/About.spec.tsx b/webview-ui/src/components/settings/__tests__/About.spec.tsx index 5266be8bae..e9ca86be37 100644 --- a/webview-ui/src/components/settings/__tests__/About.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/About.spec.tsx @@ -151,6 +151,36 @@ describe("About", () => { ) }) + it("shows the telemetry checkbox as checked when the setting is explicitly enabled", () => { + render( + + + , + ) + + expect(screen.getByRole("checkbox", { name: /telemetry/i })).toBeChecked() + }) + + it("shows the telemetry checkbox as checked when the setting is unset (disclosed opt-out default)", () => { + render( + + + , + ) + + expect(screen.getByRole("checkbox", { name: /telemetry/i })).toBeChecked() + }) + + it("does not show the telemetry checkbox as checked when the setting is disabled", () => { + render( + + + , + ) + + expect(screen.getByRole("checkbox", { name: /telemetry/i })).not.toBeChecked() + }) + it("renders export, import, and reset buttons", () => { render( diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx index d0dbdf10f4..60057ded92 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx @@ -104,6 +104,7 @@ vi.mock("../providers", () => { VercelAiGateway: provider("provider-vercel-ai-gateway"), OpenCodeGo: provider("provider-opencode-go"), Kenari: provider("provider-kenari"), + NanoGPT: provider("provider-nanogpt"), ZooGateway: provider("provider-zoo-gateway"), MiniMax: provider("provider-minimax"), Mimo: provider("provider-mimo"), @@ -350,6 +351,7 @@ describe("ApiOptions interactions", () => { providerIdentifiers.vercelAiGateway, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, + providerIdentifiers.nanogpt, providerIdentifiers.zooGateway, providerIdentifiers.fireworks, providerIdentifiers.friendli, diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx index 86e7273d45..c625650845 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx @@ -420,6 +420,7 @@ describe("ApiOptions", () => { const optionTexts = Array.from(options).map((opt) => opt.textContent) expect(optionTexts).toContain("OpenAI") expect(optionTexts).toContain("Anthropic") + expect(optionTexts).toContain("NanoGPT") // Note: The mock doesn't implement search functionality, so we're just verifying // that the select element is rendered with the expected options diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx index c798b7a4a9..37fa40024d 100644 --- a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx @@ -33,6 +33,24 @@ vi.mock("@/hooks/useAutoApprovalState", () => ({ useAutoApprovalState: () => ({ effectiveAutoApprovalEnabled: false, hasEnabledOptions: false }), })) +vi.mock("@/components/ui", async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Button: ({ children, ...props }: any) => , + Input: (props: any) => , + Slider: ({ value, onValueChange, ...props }: any) => ( + onValueChange?.([Number((event.target as HTMLInputElement).value)])} + {...props} + /> + ), + } +}) + const renderSettings = (overrides = {}) => { const setCachedStateField = vi.fn() const props = { @@ -161,4 +179,71 @@ describe("AutoApproveSettings - Save/Discard contract", () => { expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument() expect(screen.getByTestId("denied-commands-heading")).toBeInTheDocument() }) + + it("renders disabled timeout label when follow-up auto-approve timeout is 0", () => { + const { setCachedStateField } = renderSettings({ + alwaysAllowFollowupQuestions: true, + followupAutoApproveTimeoutMs: 0, + }) + + const slider = screen.getByTestId("followup-timeout-slider") as HTMLInputElement + expect(slider).toBeInTheDocument() + expect(slider.value).toBe("0") + expect(screen.getByText("settings:autoApprove.followupQuestions.timeoutDisabled")).toBeInTheDocument() + + fireEvent.change(slider, { target: { value: "4000" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("followupAutoApproveTimeoutMs", 4000) + expectNoImmediateUpdateSettings() + }) + + it("renders timeout in seconds when follow-up auto-approve timeout is non-zero", () => { + const { setCachedStateField } = renderSettings({ + alwaysAllowFollowupQuestions: true, + followupAutoApproveTimeoutMs: 5000, + }) + + const slider = screen.getByTestId("followup-timeout-slider") as HTMLInputElement + expect(slider).toBeInTheDocument() + expect(slider.value).toBe("5000") + expect(screen.getByText("5s")).toBeInTheDocument() + + fireEvent.change(slider, { target: { value: "0" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("followupAutoApproveTimeoutMs", 0) + expectNoImmediateUpdateSettings() + }) + + it("uses the default timeout value when timeout is unset and follow-up auto-approve is enabled", () => { + renderSettings({ alwaysAllowFollowupQuestions: true }) + + const slider = screen.getByTestId("followup-timeout-slider") as HTMLInputElement + expect(slider.value).toBe("60000") + expect(screen.getByText("60s")).toBeInTheDocument() + }) + + it("does not render the follow-up timeout controls when follow-up auto-approve is disabled or unset", () => { + const { rerender } = render( + , + ) + + expect(screen.queryByTestId("followup-timeout-slider")).not.toBeInTheDocument() + + rerender( + , + ) + + expect(screen.queryByTestId("followup-timeout-slider")).not.toBeInTheDocument() + }) }) diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.fixture.tsx new file mode 100644 index 0000000000..fe9c609c49 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.fixture.tsx @@ -0,0 +1,103 @@ +/* v8 ignore file -- Playwright component fixture is covered by the visual test. */ +import React from "react" +import { createInstance } from "i18next" + +import { TranslationContext } from "@/i18n/TranslationContext" +import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext" +import { TooltipProvider } from "@/components/ui/tooltip" +import { AutoApproveSettings } from "../AutoApproveSettings" + +import enSettings from "@/i18n/locales/en/settings.json" +import enCommon from "@/i18n/locales/en/common.json" + +const i18n = createInstance() + +i18n.init({ + lng: "en", + fallbackLng: "en", + resources: { + en: { + settings: enSettings, + common: enCommon, + }, + }, + interpolation: { escapeValue: false }, + initImmediate: false, +}) + +type AutoApproveFixtureProps = { + alwaysAllowReadOnly?: boolean + alwaysAllowWrite?: boolean + alwaysAllowExecute?: boolean + alwaysAllowFollowupQuestions?: boolean + followupAutoApproveTimeoutMs?: number + allowedCommands?: string[] + deniedCommands?: string[] + destructiveCommandGuardEnabled?: boolean +} + +const AutoApproveSettingsFixture = ({ + alwaysAllowReadOnly, + alwaysAllowWrite, + alwaysAllowExecute, + alwaysAllowFollowupQuestions, + followupAutoApproveTimeoutMs, + allowedCommands, + deniedCommands, + destructiveCommandGuardEnabled, +}: AutoApproveFixtureProps) => ( + i18n.t(key, options), + i18n, + }}> + + +
+ {}} + /> +
+
+
+
+) + +export const AutoApproveSettingsManualSnapshot1Fixture = () => ( + +) + +export const AutoApproveSettingsManualSnapshot2Fixture = () => ( + +) + +export const AutoApproveSettingsManualSnapshot3Fixture = () => ( + +) diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.tsx new file mode 100644 index 0000000000..5940f6368c --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.tsx @@ -0,0 +1,40 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { + AutoApproveSettingsManualSnapshot1Fixture, + AutoApproveSettingsManualSnapshot2Fixture, + AutoApproveSettingsManualSnapshot3Fixture, +} from "./AutoApproveSettings.visual.fixture" + +test("matches provided manual snapshot (1)", async ({ mount, page }) => { + await mount() + + await page.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + await expect(page.getByTestId("auto-approve-settings-visual")).toHaveScreenshot("screenshot-1-.png") +}) + +test("matches provided manual snapshot (2)", async ({ mount, page }) => { + await mount() + + await page.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(page.getByTestId("auto-approve-settings-visual")).toHaveScreenshot("screenshot-2-.png") +}) + +test("matches provided manual snapshot (3)", async ({ mount, page }) => { + await mount() + + await page.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(page.getByTestId("auto-approve-settings-visual")).toHaveScreenshot("screenshot-3-.png") +}) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 20eb7543eb..11b7de066d 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -603,4 +603,110 @@ describe("SettingsView - Unsaved Changes Detection", () => { }), ) }) + + it("buffers and saves the complete NanoGPT provider configuration from cached state", async () => { + const liveApiConfiguration = { + apiProvider: "nanogpt" as const, + nanoGptApiKey: "original-key", + nanoGptModelId: "openai/original", + nanoGptRoutingPreference: "auto" as const, + } + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultExtensionState, + apiConfiguration: liveApiConfiguration, + }) + vi.mocked(ApiOptions).mockImplementation(({ apiConfiguration, setApiConfigurationField }) => ( +
+ setApiConfigurationField("nanoGptApiKey", event.target.value)} + /> + setApiConfigurationField("nanoGptModelId", event.target.value)} + /> + +
+ )) + + renderWithExtensionState(, { queryClient }) + + expect(await screen.findByTestId("cached-nanogpt-key")).toHaveValue("original-key") + expect(screen.getByTestId("cached-nanogpt-model")).toHaveValue("openai/original") + expect(screen.getByTestId("cached-nanogpt-routing")).toHaveValue("auto") + + fireEvent.change(screen.getByTestId("cached-nanogpt-key"), { target: { value: "unsaved-key" } }) + fireEvent.change(screen.getByTestId("cached-nanogpt-model"), { target: { value: "openai/next" } }) + fireEvent.change(screen.getByTestId("cached-nanogpt-routing"), { target: { value: "tools" } }) + + expect(liveApiConfiguration).toEqual({ + apiProvider: "nanogpt", + nanoGptApiKey: "original-key", + nanoGptModelId: "openai/original", + nanoGptRoutingPreference: "auto", + }) + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) + + fireEvent.click(screen.getByTestId("save-button")) + + expect(postMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { + apiProvider: "nanogpt", + nanoGptApiKey: "unsaved-key", + nanoGptModelId: "openai/next", + nanoGptRoutingPreference: "tools", + }, + }) + }) + + it("discards NanoGPT cached edits and restores the extension values", async () => { + const onDone = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultExtensionState, + apiConfiguration: { + apiProvider: "nanogpt", + nanoGptApiKey: "saved-key", + nanoGptModelId: "openai/saved", + nanoGptRoutingPreference: "cheap", + }, + }) + vi.mocked(ApiOptions).mockImplementation(({ apiConfiguration, setApiConfigurationField }) => ( + setApiConfigurationField("nanoGptApiKey", event.target.value)} + /> + )) + + renderWithExtensionState(, { queryClient }) + fireEvent.change(await screen.findByTestId("cached-nanogpt-key"), { target: { value: "discard-me" } }) + fireEvent.click(screen.getByText("settings:common.done")) + fireEvent.click(await screen.findByText("settings:unsavedChangesDialog.discardButton")) + + await waitFor(() => expect(screen.getByTestId("cached-nanogpt-key")).toHaveValue("saved-key")) + expect(onDone).toHaveBeenCalledOnce() + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) + }) }) diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx index 5f9b74dfe0..3c97ee20e9 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx @@ -289,6 +289,26 @@ describe("ThinkingBudget", () => { expect(screen.getByTestId("select")).toHaveAttribute("data-value", "low") }) + it("should normalize an invalid disabled value to the default for required reasoning", () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "max") + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "max", false) + }) + it("should fall back to rawReasoningEffort when availableOptions is empty", () => { // Covers the ?? rawReasoningEffort branch when availableOptions[0] is undefined render( diff --git a/webview-ui/src/components/settings/__tests__/UISettings.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/UISettings.visual.fixture.tsx new file mode 100644 index 0000000000..af0352d789 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/UISettings.visual.fixture.tsx @@ -0,0 +1,53 @@ +import React, { useState } from "react" + +import type { ExtensionStateContextType } from "@/context/ExtensionStateContext" +import type { SetCachedStateField } from "../types" +import { UISettings } from "../UISettings" +import { AppProviders } from "../../../../playwright/AppProviders" + +interface UIState { + reasoningBlockCollapsed: boolean + enterBehavior: "send" | "newline" + chatFontSize?: number + autoCloseZooOpenedFiles?: boolean + autoCloseZooOpenedFilesAfterUserEdited?: boolean + autoCloseZooOpenedNewFiles?: boolean +} + +export function UISettingsStory() { + const [state, setState] = useState({ + reasoningBlockCollapsed: true, + enterBehavior: "send", + chatFontSize: 14, + autoCloseZooOpenedFiles: true, + autoCloseZooOpenedFilesAfterUserEdited: true, + autoCloseZooOpenedNewFiles: false, + }) + const setCachedStateField: SetCachedStateField = (field, value) => { + setState((current) => { + switch (field) { + case "reasoningBlockCollapsed": + case "autoCloseZooOpenedFiles": + case "autoCloseZooOpenedFilesAfterUserEdited": + case "autoCloseZooOpenedNewFiles": + return { ...current, [field]: Boolean(value) } + case "enterBehavior": + return { ...current, enterBehavior: value === "newline" ? "newline" : "send" } + case "chatFontSize": + return { ...current, chatFontSize: typeof value === "number" ? value : undefined } + default: + return current + } + }) + } + + return ( + +
+ +
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/UISettings.visual.tsx b/webview-ui/src/components/settings/__tests__/UISettings.visual.tsx new file mode 100644 index 0000000000..65c8252a37 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/UISettings.visual.tsx @@ -0,0 +1,23 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { expectContrast } from "../../../../playwright/contrast" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" +import { UISettingsStory } from "./UISettings.visual.fixture" + +for (const theme of visualThemes) { + test(`renders the production UI settings in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + await applyVisualTheme(page, theme) + // The full provider bundle leaves a bare Zod reference after CT tree-shaking. + await page.evaluate(() => Object.assign(globalThis, { z: undefined })) + const component = await mount() + const story = component.getByTestId("ui-settings-story") + const heading = story.getByRole("heading", { name: "UI" }) + await expect(heading).toBeVisible() + await expectContrast(heading, { + background: heading.locator(".."), + label: `${theme.name} UI settings heading`, + }) + await expect(story).toHaveScreenshot(`ui-settings-${theme.name}.png`) + }) +} diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/model-info-service-tier-pricing-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/model-info-service-tier-pricing-dark.png index 0a3bee5351..287c7f0486 100644 Binary files a/webview-ui/src/components/settings/__tests__/__screenshots__/model-info-service-tier-pricing-dark.png and b/webview-ui/src/components/settings/__tests__/__screenshots__/model-info-service-tier-pricing-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/screenshot-1-.png b/webview-ui/src/components/settings/__tests__/__screenshots__/screenshot-1-.png new file mode 100644 index 0000000000..67fb892e52 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/screenshot-1-.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/screenshot-2-.png b/webview-ui/src/components/settings/__tests__/__screenshots__/screenshot-2-.png new file mode 100644 index 0000000000..6d98a1c51a Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/screenshot-2-.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/screenshot-3-.png b/webview-ui/src/components/settings/__tests__/__screenshots__/screenshot-3-.png new file mode 100644 index 0000000000..787280852e Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/screenshot-3-.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-dark.png new file mode 100644 index 0000000000..b3f708f180 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast-light.png new file mode 100644 index 0000000000..198989b164 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast-light.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast.png new file mode 100644 index 0000000000..56bb082d0a Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-light.png new file mode 100644 index 0000000000..1d362dd185 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-light.png differ diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 7e5e10db6b..8c51aca2fe 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -70,6 +70,7 @@ export const PROVIDERS: Array<{ value: string; label: string; proxy: boolean }> { value: providerIdentifiers.vercelAiGateway, label: "Vercel AI Gateway", proxy: false }, { value: providerIdentifiers.opencodeGo, label: "Opencode Go", proxy: false }, { value: providerIdentifiers.kenari, label: "Kenari", proxy: false }, + { value: providerIdentifiers.nanogpt, label: "NanoGPT", proxy: false }, { value: providerIdentifiers.zooGateway, label: "Zoo Gateway", proxy: false }, { value: providerIdentifiers.minimax, label: "MiniMax", proxy: false }, { value: providerIdentifiers.mimo, label: "Xiaomi MiMo", proxy: false }, diff --git a/webview-ui/src/components/settings/providers/Kenari.tsx b/webview-ui/src/components/settings/providers/Kenari.tsx index 577a44ac71..e8d2f5cdb9 100644 --- a/webview-ui/src/components/settings/providers/Kenari.tsx +++ b/webview-ui/src/components/settings/providers/Kenari.tsx @@ -6,6 +6,7 @@ import { type OrganizationAllowList, type RouterModels, kenariDefaultModelId, + providerIdentifiers, } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -69,7 +70,7 @@ export const Kenari = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} defaultModelId={kenariDefaultModelId} - models={routerModels?.["kenari"] ?? {}} + models={routerModels?.[providerIdentifiers.kenari] ?? {}} modelIdKey="kenariModelId" serviceName="Kenari" serviceUrl="https://kenari.id/docs" diff --git a/webview-ui/src/components/settings/providers/KimiCode.tsx b/webview-ui/src/components/settings/providers/KimiCode.tsx index 4e9d3ff561..a0350c9471 100644 --- a/webview-ui/src/components/settings/providers/KimiCode.tsx +++ b/webview-ui/src/components/settings/providers/KimiCode.tsx @@ -7,6 +7,8 @@ import { type KimiCodeAuthMethod, type ModelRecord, type ProviderSettings, + providerIdentifiers, + RouterModelsMessageType, } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -37,10 +39,10 @@ export const KimiCode = ({ const { t } = useAppTranslation() const authMethod = apiConfiguration.kimiCodeAuthMethod ?? "oauth" const { data, refetch, isFetching } = useRouterModels({ - provider: "kimi-code", + provider: providerIdentifiers.kimiCode, enabled: authMethod === "oauth" ? kimiCodeIsAuthenticated : !!apiConfiguration.kimiCodeApiKey, }) - const discoveredModels = data?.["kimi-code"] + const discoveredModels = data?.[providerIdentifiers.kimiCode] const models: ModelRecord = discoveredModels && Object.keys(discoveredModels).length > 0 ? discoveredModels : kimiCodeModels @@ -50,9 +52,9 @@ export const KimiCode = ({ const refreshModels = () => { vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { - provider: "kimi-code", + provider: providerIdentifiers.kimiCode, refresh: true, kimiCodeAuthMethod: authMethod, kimiCodeApiKey: apiConfiguration.kimiCodeApiKey, diff --git a/webview-ui/src/components/settings/providers/LMStudio.tsx b/webview-ui/src/components/settings/providers/LMStudio.tsx index 786c3f4474..64c12606c1 100644 --- a/webview-ui/src/components/settings/providers/LMStudio.tsx +++ b/webview-ui/src/components/settings/providers/LMStudio.tsx @@ -4,7 +4,12 @@ import { Trans } from "react-i18next" import { Checkbox } from "vscrui" import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import type { ProviderSettings, ExtensionMessage, ModelRecord } from "@roo-code/types" +import { + type ProviderSettings, + type ExtensionMessage, + type ModelRecord, + LmStudioModelsMessageType, +} from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { requestLmStudioModels } from "@src/components/ui/hooks/useLmStudioModels" @@ -40,7 +45,7 @@ export const LMStudio = ({ apiConfiguration, setApiConfigurationField }: LMStudi const message: ExtensionMessage = event.data switch (message.type) { - case "lmStudioModels": + case LmStudioModelsMessageType.lmStudioModels: { const newModels = message.lmStudioModels ?? {} setLmStudioModels(newModels) diff --git a/webview-ui/src/components/settings/providers/LiteLLM.tsx b/webview-ui/src/components/settings/providers/LiteLLM.tsx index 2a8dcb8d67..5f3b7dc27b 100644 --- a/webview-ui/src/components/settings/providers/LiteLLM.tsx +++ b/webview-ui/src/components/settings/providers/LiteLLM.tsx @@ -7,6 +7,9 @@ import { type OrganizationAllowList, type ExtensionMessage, litellmDefaultModelId, + providerIdentifiers, + allRouterModelsProvider, + RouterModelsMessageType, } from "@roo-code/types" import { RouterName } from "@roo/api" @@ -27,6 +30,13 @@ type LiteLLMProps = { simplifySettings?: boolean } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const LiteLLM = ({ apiConfiguration, setApiConfigurationField, @@ -37,32 +47,35 @@ export const LiteLLM = ({ const { t } = useAppTranslation() const queryClient = useQueryClient() const { routerModels } = useExtensionState() - const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) const [refreshError, setRefreshError] = useState() const litellmErrorJustReceived = useRef(false) useEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data - if (message.type === "singleRouterModelFetchResponse" && !message.success) { + if (message.type === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { const providerName = message.values?.provider as RouterName - if (providerName === "litellm") { + if (providerName === providerIdentifiers.litellm) { litellmErrorJustReceived.current = true - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(message.error) } - } else if (message.type === "routerModels") { + } else if (message.type === RouterModelsMessageType.routerModels) { // If we were loading and no specific error for litellm was just received, mark as success. // The ModelPicker will show available models or "no models found". - if (refreshStatus === "loading") { + if (refreshStatus === RefreshStatus.Loading) { if (!litellmErrorJustReceived.current) { - setRefreshStatus("success") - // Invalidate only the LiteLLM router-models query so useSelectedModel - // picks up the refreshed list. useSelectedModel reads LiteLLM under the - // compound key ["routerModels", "litellm"] (see useRouterModels), so we - // target that exact key rather than the bare ["routerModels"] prefix, - // which would needlessly invalidate every other provider's query too. - queryClient.invalidateQueries({ queryKey: ["routerModels", "litellm"] }) + setRefreshStatus(RefreshStatus.Success) + // Refresh the provider-scoped cache used by useSelectedModel and the shared cache used by + // ApiOptions. Target both exact keys rather than the bare ["routerModels"] prefix, which + // would needlessly invalidate every other provider's query too. + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.litellm], + }) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], + }) } // If litellmErrorJustReceived.current is true, status is already (or will be) "error". } @@ -88,19 +101,22 @@ export const LiteLLM = ({ const handleRefreshModels = useCallback(() => { litellmErrorJustReceived.current = false // Reset flag on new refresh action - setRefreshStatus("loading") + setRefreshStatus(RefreshStatus.Loading) setRefreshError(undefined) const key = apiConfiguration.litellmApiKey const url = apiConfiguration.litellmBaseUrl if (!key || !url) { - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(t("settings:providers.refreshModels.missingConfig")) return } - vscode.postMessage({ type: "requestRouterModels", values: { litellmApiKey: key, litellmBaseUrl: url } }) + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + values: { litellmApiKey: key, litellmBaseUrl: url }, + }) }, [apiConfiguration, setRefreshStatus, setRefreshError, t]) return ( @@ -130,11 +146,13 @@ export const LiteLLM = ({ variant="outline" onClick={handleRefreshModels} disabled={ - refreshStatus === "loading" || !apiConfiguration.litellmApiKey || !apiConfiguration.litellmBaseUrl + refreshStatus === RefreshStatus.Loading || + !apiConfiguration.litellmApiKey || + !apiConfiguration.litellmBaseUrl } className="w-full">
- {refreshStatus === "loading" ? ( + {refreshStatus === RefreshStatus.Loading ? ( ) : ( @@ -142,15 +160,15 @@ export const LiteLLM = ({ {t("settings:providers.refreshModels.label")}
- {refreshStatus === "loading" && ( + {refreshStatus === RefreshStatus.Loading && (
{t("settings:providers.refreshModels.loading")}
)} - {refreshStatus === "success" && ( + {refreshStatus === RefreshStatus.Success && (
{t("settings:providers.refreshModels.success")}
)} - {refreshStatus === "error" && ( + {refreshStatus === RefreshStatus.Error && (
{refreshError || t("settings:providers.refreshModels.error")}
diff --git a/webview-ui/src/components/settings/providers/Moonshot.tsx b/webview-ui/src/components/settings/providers/Moonshot.tsx index 2d6c7d849a..ed561f2b9f 100644 --- a/webview-ui/src/components/settings/providers/Moonshot.tsx +++ b/webview-ui/src/components/settings/providers/Moonshot.tsx @@ -2,8 +2,14 @@ import { useCallback, useState, useEffect, useRef } from "react" import { VSCodeTextField, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" import { useQueryClient } from "@tanstack/react-query" -import type { ProviderSettings, ExtensionMessage } from "@roo-code/types" -import { moonshotDefaultModelId } from "@roo-code/types" +import { + type ProviderSettings, + type ExtensionMessage, + moonshotDefaultModelId, + providerIdentifiers, + allRouterModelsProvider, + RouterModelsMessageType, +} from "@roo-code/types" import { RouterName } from "@roo/api" @@ -14,7 +20,6 @@ import { vscode } from "@src/utils/vscode" import { Button } from "@src/components/ui" import { ModelPicker } from "../ModelPicker" import { handleModelChangeSideEffects } from "../utils/providerModelConfig" -import type { ProviderName } from "@roo-code/types" import { inputEventTransform } from "../transforms" @@ -24,29 +29,41 @@ type MoonshotProps = { simplifySettings?: boolean } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const Moonshot = ({ apiConfiguration, setApiConfigurationField, simplifySettings }: MoonshotProps) => { const { t } = useAppTranslation() const { routerModels } = useExtensionState() const queryClient = useQueryClient() - const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) const [refreshError, setRefreshError] = useState() const moonshotErrorJustReceived = useRef(false) useEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data - if (message.type === "singleRouterModelFetchResponse" && !message.success) { + if (message.type === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { const providerName = message.values?.provider as RouterName - if (providerName === "moonshot" && refreshStatus === "loading") { + if (providerName === providerIdentifiers.moonshot && refreshStatus === RefreshStatus.Loading) { moonshotErrorJustReceived.current = true - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(message.error) } - } else if (message.type === "routerModels") { - if (refreshStatus === "loading") { + } else if (message.type === RouterModelsMessageType.routerModels) { + if (refreshStatus === RefreshStatus.Loading) { if (!moonshotErrorJustReceived.current) { - setRefreshStatus("success") - queryClient.invalidateQueries({ queryKey: ["routerModels"] }) + setRefreshStatus(RefreshStatus.Success) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.moonshot], + }) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], + }) } } } @@ -71,19 +88,19 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField, simplifyS const handleRefreshModels = useCallback(() => { moonshotErrorJustReceived.current = false - setRefreshStatus("loading") + setRefreshStatus(RefreshStatus.Loading) setRefreshError(undefined) const key = apiConfiguration.moonshotApiKey if (!key) { - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(t("settings:providers.refreshModels.missingConfig")) return } vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { moonshotApiKey: key, moonshotBaseUrl: apiConfiguration.moonshotBaseUrl }, }) }, [apiConfiguration, t]) @@ -138,15 +155,15 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField, simplifyS serviceUrl="https://platform.moonshot.ai" simplifySettings={simplifySettings} onModelChange={(modelId) => - handleModelChangeSideEffects("moonshot" as ProviderName, modelId, setApiConfigurationField) + handleModelChangeSideEffects(providerIdentifiers.moonshot, modelId, setApiConfigurationField) } /> - {refreshStatus === "loading" && ( + {refreshStatus === RefreshStatus.Loading && (
{t("settings:providers.refreshModels.loading")}
)} - {refreshStatus === "success" && ( + {refreshStatus === RefreshStatus.Success && (
{t("settings:providers.refreshModels.success")}
)} - {refreshStatus === "error" && ( + {refreshStatus === RefreshStatus.Error && (
{refreshError || t("settings:providers.refreshModels.error")}
diff --git a/webview-ui/src/components/settings/providers/NanoGPT.tsx b/webview-ui/src/components/settings/providers/NanoGPT.tsx new file mode 100644 index 0000000000..11e0005604 --- /dev/null +++ b/webview-ui/src/components/settings/providers/NanoGPT.tsx @@ -0,0 +1,143 @@ +import { useCallback, useEffect } from "react" +import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { + type NanoGptRoutingPreference, + type OrganizationAllowList, + type ProviderSettings, + type RouterModels, + nanoGptDefaultModelId, + nanoGptDefaultRoutingPreference, + nanoGptRoutingPreferences, + providerIdentifiers, + RouterModelsMessageType, +} from "@roo-code/types" + +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { vscode } from "@src/utils/vscode" + +import { ModelPicker } from "../ModelPicker" +import { inputEventTransform } from "../transforms" + +type NanoGPTProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: K, value: ProviderSettings[K]) => void + routerModels?: RouterModels + organizationAllowList: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean +} + +const routingOptionKeys: Record = { + auto: "automatic", + fast: "fastest", + cheap: "cheapest", + latency: "lowestLatency", + throughput: "highestThroughput", + tools: "toolCapable", + caching: "cacheCapable", +} + +export const NanoGPT = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, + simplifySettings, +}: NanoGPTProps) => { + const { t } = useAppTranslation() + const routingPreference = apiConfiguration.nanoGptRoutingPreference ?? nanoGptDefaultRoutingPreference + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + useEffect(() => { + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.nanogpt, + nanoGptApiKey: apiConfiguration.nanoGptApiKey, + }, + }) + }, [apiConfiguration.nanoGptApiKey]) + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration.nanoGptApiKey && ( + + {t("settings:providers.nanoGpt.getApiKey")} + + )} + + + +
+ + +
+ {t("settings:providers.nanoGpt.automaticExplanation")} +
+
+ + {routingPreference !== nanoGptDefaultRoutingPreference && ( +
+

{t("settings:providers.nanoGpt.billingWarning")}

+ + {t("settings:providers.nanoGpt.routingDocs")} + +
+ )} + + ) +} diff --git a/webview-ui/src/components/settings/providers/Ollama.tsx b/webview-ui/src/components/settings/providers/Ollama.tsx index 9b11c85369..8d1e7348f4 100644 --- a/webview-ui/src/components/settings/providers/Ollama.tsx +++ b/webview-ui/src/components/settings/providers/Ollama.tsx @@ -2,7 +2,13 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { Checkbox } from "vscrui" -import { type ProviderSettings, type ExtensionMessage, type ModelRecord, ollamaDefaultModelInfo } from "@roo-code/types" +import { + type ProviderSettings, + type ExtensionMessage, + type ModelRecord, + ollamaDefaultModelInfo, + OllamaModelsMessageType, +} from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" @@ -18,11 +24,18 @@ type OllamaProps = { setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaProps) => { const { t } = useAppTranslation() const [ollamaModels, setOllamaModels] = useState({}) - const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) const [refreshError, setRefreshError] = useState() const refreshStatusRef = useRef(refreshStatus) const routerModels = useRouterModels() @@ -42,13 +55,13 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro const handleMessage = (event: MessageEvent) => { const message: ExtensionMessage = event.data - if (message.type === "ollamaModels") { + if (message.type === OllamaModelsMessageType.ollamaModels) { if (!message.error) { setOllamaModels(message.ollamaModels ?? {}) } - if (refreshStatusRef.current === "loading") { - const nextStatus = message.error ? "error" : "success" + if (refreshStatusRef.current === RefreshStatus.Loading) { + const nextStatus = message.error ? RefreshStatus.Error : RefreshStatus.Success refreshStatusRef.current = nextStatus setRefreshStatus(nextStatus) setRefreshError(message.error) @@ -63,11 +76,11 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro }, []) const handleRefreshModels = useCallback(() => { - refreshStatusRef.current = "loading" - setRefreshStatus("loading") + refreshStatusRef.current = RefreshStatus.Loading + setRefreshStatus(RefreshStatus.Loading) setRefreshError(undefined) vscode.postMessage({ - type: "requestOllamaModels", + type: OllamaModelsMessageType.requestOllamaModels, values: { baseUrl: apiConfiguration?.ollamaBaseUrl, apiKey: apiConfiguration?.ollamaApiKey, @@ -78,7 +91,7 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro // Refresh models on mount useEffect(() => { // Request fresh models - the handler now flushes cache automatically - vscode.postMessage({ type: "requestOllamaModels" }) + vscode.postMessage({ type: OllamaModelsMessageType.requestOllamaModels }) }, []) // Check if the selected model exists in the fetched models @@ -130,10 +143,10 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro - {refreshStatus === "loading" && ( + {refreshStatus === RefreshStatus.Loading && (
{t("settings:providers.refreshModels.loading")}
)} - {refreshStatus === "success" && ( + {refreshStatus === RefreshStatus.Success && (
{t("settings:providers.refreshModels.success")}
)} - {refreshStatus === "error" && ( + {refreshStatus === RefreshStatus.Error && (
{refreshError || t("settings:providers.refreshModels.error")}
diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index f9a021812b..7870b21f32 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -12,6 +12,7 @@ import { azureOpenAiDefaultApiVersion, isAzureOpenAiBaseUrl, openAiModelInfoSaneDefaults, + OpenAiModelsMessageType, } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -115,7 +116,7 @@ export const OpenAICompatible = ({ const message: ExtensionMessage = event.data switch (message.type) { - case "openAiModels": { + case OpenAiModelsMessageType.openAiModels: { const updatedModels = message.openAiModels ?? [] setOpenAiModels(Object.fromEntries(updatedModels.map((item) => [item, openAiModelInfoSaneDefaults]))) break diff --git a/webview-ui/src/components/settings/providers/OpenCodeGo.tsx b/webview-ui/src/components/settings/providers/OpenCodeGo.tsx index f004d32f91..249e2de534 100644 --- a/webview-ui/src/components/settings/providers/OpenCodeGo.tsx +++ b/webview-ui/src/components/settings/providers/OpenCodeGo.tsx @@ -7,6 +7,8 @@ import { type RouterModels, type ExtensionMessage, opencodeGoDefaultModelId, + providerIdentifiers, + RouterModelsMessageType, } from "@roo-code/types" import type { RouterName } from "@roo/api" @@ -28,6 +30,13 @@ type OpenCodeGoProps = { simplifySettings?: boolean } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const OpenCodeGo = ({ apiConfiguration, setApiConfigurationField, @@ -37,24 +46,24 @@ export const OpenCodeGo = ({ simplifySettings, }: OpenCodeGoProps) => { const { t } = useAppTranslation() - const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) const [refreshError, setRefreshError] = useState() const errorJustReceived = useRef(false) useEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data - if (message.type === "singleRouterModelFetchResponse" && !message.success) { + if (message.type === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { const providerName = message.values?.provider as RouterName - if (providerName === "opencode-go") { + if (providerName === providerIdentifiers.opencodeGo) { errorJustReceived.current = true - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(message.error) } - } else if (message.type === "routerModels") { - if (refreshStatus === "loading") { + } else if (message.type === RouterModelsMessageType.routerModels) { + if (refreshStatus === RefreshStatus.Loading) { if (!errorJustReceived.current) { - setRefreshStatus("success") + setRefreshStatus(RefreshStatus.Success) } } } @@ -79,11 +88,15 @@ export const OpenCodeGo = ({ const handleRefreshModels = useCallback(() => { errorJustReceived.current = false - setRefreshStatus("loading") + setRefreshStatus(RefreshStatus.Loading) setRefreshError(undefined) vscode.postMessage({ - type: "requestRouterModels", - values: { provider: "opencode-go", refresh: true, opencodeGoApiKey: apiConfiguration.opencodeGoApiKey }, + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.opencodeGo, + refresh: true, + opencodeGoApiKey: apiConfiguration.opencodeGoApiKey, + }, }) }, [apiConfiguration.opencodeGoApiKey]) @@ -108,10 +121,10 @@ export const OpenCodeGo = ({ - {refreshStatus === "loading" && ( + {refreshStatus === RefreshStatus.Loading && (
{t("settings:providers.refreshModels.loading")}
)} - {refreshStatus === "success" && ( + {refreshStatus === RefreshStatus.Success && (
{t("settings:providers.refreshModels.success")}
)} - {refreshStatus === "error" && ( + {refreshStatus === RefreshStatus.Error && (
{refreshError || t("settings:providers.refreshModels.error")}
@@ -136,7 +149,7 @@ export const OpenCodeGo = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} defaultModelId={opencodeGoDefaultModelId} - models={routerModels?.["opencode-go"] ?? {}} + models={routerModels?.[providerIdentifiers.opencodeGo] ?? {}} modelIdKey="opencodeGoModelId" serviceName="Opencode Go" serviceUrl="https://opencode.ai/docs/go/" diff --git a/webview-ui/src/components/settings/providers/Poe.tsx b/webview-ui/src/components/settings/providers/Poe.tsx index 7b79ed8510..b549b8aae1 100644 --- a/webview-ui/src/components/settings/providers/Poe.tsx +++ b/webview-ui/src/components/settings/providers/Poe.tsx @@ -7,7 +7,9 @@ import { type OrganizationAllowList, type ExtensionMessage, poeDefaultModelId, - type ProviderName, + providerIdentifiers, + allRouterModelsProvider, + RouterModelsMessageType, } from "@roo-code/types" import { RouterName } from "@roo/api" @@ -30,6 +32,13 @@ type PoeProps = { simplifySettings?: boolean } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const Poe = ({ apiConfiguration, setApiConfigurationField, @@ -40,27 +49,32 @@ export const Poe = ({ const { t } = useAppTranslation() const queryClient = useQueryClient() const { routerModels } = useExtensionState() - const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) const [refreshError, setRefreshError] = useState() const poeErrorJustReceived = useRef(false) useEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data - if (message.type === "singleRouterModelFetchResponse" && !message.success) { + if (message.type === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { const providerName = message.values?.provider as RouterName - if (providerName === "poe") { + if (providerName === providerIdentifiers.poe) { poeErrorJustReceived.current = true - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(message.error) } - } else if (message.type === "routerModels") { - if (refreshStatus === "loading") { + } else if (message.type === RouterModelsMessageType.routerModels) { + if (refreshStatus === RefreshStatus.Loading) { if (!poeErrorJustReceived.current) { - setRefreshStatus("success") - // Invalidate the react-query router models cache so - // validation in ApiOptions picks up the refreshed list. - queryClient.invalidateQueries({ queryKey: ["routerModels"] }) + setRefreshStatus(RefreshStatus.Success) + // Refresh the provider-scoped cache used by useSelectedModel and the shared cache used by + // ApiOptions without invalidating every other provider's query. + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.poe], + }) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], + }) } } } @@ -85,19 +99,19 @@ export const Poe = ({ const handleRefreshModels = useCallback(() => { poeErrorJustReceived.current = false - setRefreshStatus("loading") + setRefreshStatus(RefreshStatus.Loading) setRefreshError(undefined) const key = apiConfiguration.poeApiKey if (!key) { - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(t("settings:providers.refreshModels.missingConfig")) return } vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { poeApiKey: key, poeBaseUrl: apiConfiguration.poeBaseUrl }, }) }, [apiConfiguration, t]) @@ -123,9 +137,9 @@ export const Poe = ({ - {refreshStatus === "loading" && ( + {refreshStatus === RefreshStatus.Loading && (
{t("settings:providers.refreshModels.loading")}
)} - {refreshStatus === "success" && ( + {refreshStatus === RefreshStatus.Success && (
{t("settings:providers.refreshModels.success")}
)} - {refreshStatus === "error" && ( + {refreshStatus === RefreshStatus.Error && (
{refreshError || t("settings:providers.refreshModels.error")}
@@ -158,7 +172,7 @@ export const Poe = ({ errorMessage={modelValidationError} simplifySettings={simplifySettings} onModelChange={(modelId) => - handleModelChangeSideEffects("poe" as ProviderName, modelId, setApiConfigurationField) + handleModelChangeSideEffects(providerIdentifiers.poe, modelId, setApiConfigurationField) } /> diff --git a/webview-ui/src/components/settings/providers/Requesty.tsx b/webview-ui/src/components/settings/providers/Requesty.tsx index ba24a6aafb..4149dbc2c8 100644 --- a/webview-ui/src/components/settings/providers/Requesty.tsx +++ b/webview-ui/src/components/settings/providers/Requesty.tsx @@ -6,6 +6,8 @@ import { type OrganizationAllowList, type RouterModels, requestyDefaultModelId, + providerIdentifiers, + RouterModelsMessageType, } from "@roo-code/types" import { vscode } from "@src/utils/vscode" @@ -59,7 +61,7 @@ export const Requesty = ({ ) const getApiKeyUrl = () => { - const callbackUrl = getCallbackUrl("requesty", uriScheme) + const callbackUrl = getCallbackUrl(providerIdentifiers.requesty, uriScheme) const baseUrl = toRequestyServiceUrl(apiConfiguration.requestyBaseUrl, "app") const authUrl = new URL(`oauth/authorize?callback_url=${callbackUrl}`, baseUrl) @@ -129,7 +131,10 @@ export const Requesty = ({ + ), +})) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: () =>
, +})) + +describe("LiteLLM", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + + beforeEach(() => { + vi.clearAllMocks() + mockUseExtensionState.mockReturnValue({ routerModels: { [providerIdentifiers.litellm]: {} } }) + }) + + it("invalidates both LiteLLM caches after a successful model refresh", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.litellm, + litellmApiKey: "test-key", + litellmBaseUrl: "http://localhost:4000", + } + + render( + + + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", providerIdentifiers.litellm], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["routerModels", allRouterModelsProvider] }) + }) + }) + + it("recognizes failed refresh responses for the canonical LiteLLM provider", () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: providerIdentifiers.litellm }, + error: "LiteLLM unavailable", + }, + }), + ) + }) + + expect(screen.getByText("LiteLLM unavailable")).toBeInTheDocument() + }) + + it("does not invalidate caches when a LiteLLM error and router models arrive in the same tick", () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + render( + + + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: providerIdentifiers.litellm }, + error: "LiteLLM unavailable", + }, + }), + ) + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + expect(screen.getByText("LiteLLM unavailable")).toBeInTheDocument() + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it("ignores failed refresh responses for another provider", () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: providerIdentifiers.openrouter }, + error: "OpenRouter unavailable", + }, + }), + ) + }) + + expect(screen.queryByText("OpenRouter unavailable")).not.toBeInTheDocument() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx index 1ffcf9b683..1cffc1d5ba 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx @@ -2,7 +2,8 @@ import React from "react" import { render, screen, fireEvent, waitFor, act } from "@/utils/test-utils" -import type { ProviderSettings } from "@roo-code/types" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { allRouterModelsProvider, providerIdentifiers, type ProviderSettings } from "@roo-code/types" import { Moonshot } from "../Moonshot" @@ -47,13 +48,18 @@ vi.mock("@vscode/webview-ui-toolkit/react", async (importOriginal) => { }) // Mock the ModelPicker - must be a simple component that doesn't import anything -vi.mock("../ModelPicker", () => ({ - ModelPicker: function MockModelPicker() { +vi.mock("../../ModelPicker", () => ({ + ModelPicker: function MockModelPicker({ onModelChange }: { onModelChange?: (modelId: string) => void }) { return React.createElement( "div", { "data-testid": "model-picker" }, React.createElement("span", { "data-testid": "model-picker-default" }, "mock-default"), React.createElement("span", { "data-testid": "model-picker-count" }, "0"), + React.createElement( + "button", + { "data-testid": "change-model", onClick: () => onModelChange?.("moonshot-v1-128k") }, + "Change model", + ), ) }, })) @@ -103,11 +109,6 @@ vi.mock("@src/components/common/VSCodeButtonLink", () => ({ ), })) -// Mock handleModelChangeSideEffects -vi.mock("../utils/providerModelConfig", () => ({ - handleModelChangeSideEffects: vi.fn(), -})) - import { useExtensionState } from "@src/context/ExtensionStateContext" import { vscode } from "@src/utils/vscode" @@ -117,7 +118,7 @@ describe("Moonshot Component", () => { const mockSetApiConfigurationField = vi.fn() const createDefaultApiConfiguration = (overrides?: Partial): ProviderSettings => ({ - apiProvider: "moonshot", + apiProvider: providerIdentifiers.moonshot, moonshotBaseUrl: "https://api.moonshot.ai/v1", ...overrides, }) @@ -223,6 +224,38 @@ describe("Moonshot Component", () => { }) }) + it("invalidates only the Moonshot and shared router-model caches after a successful refresh", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + render( + + + , + ) + + const refreshButton = screen + .getAllByTestId("button") + .find((button) => button.getAttribute("data-variant") === "outline")! + fireEvent.click(refreshButton) + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", providerIdentifiers.moonshot], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", allRouterModelsProvider], + }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ["routerModels"] }) + }) + }) + it("shows error state after singleRouterModelFetchResponse error message", async () => { mockUseExtensionState.mockReturnValue({ routerModels: {}, @@ -258,7 +291,7 @@ describe("Moonshot Component", () => { type: "singleRouterModelFetchResponse", success: false, error: "API connection failed", - values: { provider: "moonshot" }, + values: { provider: providerIdentifiers.moonshot }, }, "*", ) @@ -274,6 +307,77 @@ describe("Moonshot Component", () => { }) }) + it("ignores another provider's failed refresh response while loading", async () => { + render( + , + ) + + const refreshButton = screen + .getAllByTestId("button") + .find((button) => button.getAttribute("data-variant") === "outline")! + fireEvent.click(refreshButton) + await waitFor(() => expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + error: "OpenRouter unavailable", + values: { provider: providerIdentifiers.openrouter }, + }, + }), + ) + }) + + expect(screen.queryByText("OpenRouter unavailable")).not.toBeInTheDocument() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("ignores a Moonshot failure response before refresh starts", () => { + render( + , + ) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + error: "Moonshot unavailable", + values: { provider: providerIdentifiers.moonshot }, + }, + }), + ) + }) + + expect(screen.queryByText("Moonshot unavailable")).not.toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.loading")).not.toBeInTheDocument() + }) + + it("resets model-specific settings when the selected model changes", () => { + render( + , + ) + + fireEvent.click(screen.getByTestId("change-model")) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", undefined) + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", undefined) + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", undefined) + }) + it("race condition: error arrives before routerModels success — stays in error state", async () => { mockUseExtensionState.mockReturnValue({ routerModels: {}, @@ -308,7 +412,7 @@ describe("Moonshot Component", () => { type: "singleRouterModelFetchResponse", success: false, error: "API connection failed", - values: { provider: "moonshot" }, + values: { provider: providerIdentifiers.moonshot }, }, "*", ) diff --git a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx new file mode 100644 index 0000000000..bb810caa75 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx @@ -0,0 +1,183 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { + type OrganizationAllowList, + type ProviderSettings, + type RouterModels, + nanoGptDefaultModelId, + nanoGptRoutingPreferences, + providerIdentifiers, + RouterModelsMessageType, +} from "@roo-code/types" + +import { NanoGPT } from "../NanoGPT" + +const { postMessageMock } = vi.hoisted(() => ({ postMessageMock: vi.fn() })) + +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: postMessageMock } })) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ + children, + value, + onInput, + type, + }: React.ComponentProps<"input"> & { children: React.ReactNode }) => ( +
+ {children} + +
+ ), + VSCodeLink: ({ children, href }: React.ComponentProps<"a">) => {children}, +})) + +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ children, href }: React.ComponentProps<"a">) => ( + + {children} + + ), +})) + +vi.mock("@src/components/ui", () => ({ + Select: ({ + children, + value, + onValueChange, + }: { + children: React.ReactNode + value: string + onValueChange: (value: string) => void + }) => ( + + ), + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => ( + + ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectValue: () => null, +})) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: ({ + defaultModelId, + models, + modelIdKey, + serviceName, + }: { + defaultModelId: string + models: object + modelIdKey: string + serviceName: string + }) => ( +
+ ), +})) + +describe("NanoGPT", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + const setApiConfigurationField = vi.fn() + const routerModels: RouterModels = { + openrouter: {}, + "vercel-ai-gateway": {}, + "zoo-gateway": {}, + litellm: {}, + requesty: {}, + unbound: {}, + poe: {}, + deepseek: {}, + moonshot: {}, + "opencode-go": {}, + kenari: {}, + nanogpt: { "openai/test": { contextWindow: 1, maxTokens: 1, supportsPromptCache: false } }, + "kimi-code": {}, + ollama: {}, + lmstudio: {}, + } + + const renderComponent = (apiConfiguration: ProviderSettings = {}) => + render( + , + ) + + beforeEach(() => vi.clearAllMocks()) + + it("renders the secret key input, CTA, dynamic model picker, routing copy, and links", () => { + renderComponent({ nanoGptRoutingPreference: "fast" }) + + expect(screen.getByTestId("nanogpt-api-key")).toHaveAttribute("type", "password") + expect(screen.getByText("settings:providers.nanoGpt.apiKey")).toBeInTheDocument() + expect(screen.getByTestId("nanogpt-get-key")).toHaveAttribute("href", "https://nano-gpt.com/api") + expect(screen.getByText("settings:providers.nanoGpt.getApiKey")).toBeInTheDocument() + expect(screen.getByTestId("model-picker")).toHaveAttribute("data-default-model-id", nanoGptDefaultModelId) + expect(screen.getByTestId("model-picker")).toHaveAttribute("data-model-id-key", "nanoGptModelId") + expect(screen.getByTestId("model-picker")).toHaveAttribute("data-model-count", "1") + expect(screen.getByText("settings:providers.nanoGpt.automaticExplanation")).toBeInTheDocument() + expect(screen.getByText("settings:providers.nanoGpt.billingWarning")).toBeInTheDocument() + expect(screen.getByText("settings:providers.nanoGpt.routingDocs")).toHaveAttribute( + "href", + "https://docs.nano-gpt.com/api-reference/miscellaneous/provider-selection", + ) + }) + + it("defaults routing to automatic and renders every routing option", () => { + renderComponent() + + expect(screen.getByTestId("routing-select")).toHaveValue("auto") + expect(screen.getAllByRole("option").map((option) => option.getAttribute("value"))).toEqual([ + ...nanoGptRoutingPreferences, + ]) + expect(screen.queryByText("settings:providers.nanoGpt.billingWarning")).not.toBeInTheDocument() + expect(screen.queryByText("settings:providers.nanoGpt.routingDocs")).not.toBeInTheDocument() + }) + + it("updates the cached key and routing preference with exact values", () => { + renderComponent({ nanoGptApiKey: "", nanoGptRoutingPreference: "fast" }) + + fireEvent.input(screen.getByTestId("nanogpt-api-key"), { target: { value: "new-secret" } }) + fireEvent.change(screen.getByTestId("routing-select"), { target: { value: "tools" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("nanoGptApiKey", "new-secret") + expect(setApiConfigurationField).toHaveBeenCalledWith("nanoGptRoutingPreference", "tools") + }) + + it("refreshes models with the unsaved cached key whenever it changes", () => { + const { rerender } = renderComponent({ nanoGptApiKey: "first-key" }) + expect(postMessageMock).toHaveBeenLastCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.nanogpt, nanoGptApiKey: "first-key" }, + }) + + rerender( + , + ) + + expect(postMessageMock).toHaveBeenLastCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.nanogpt, nanoGptApiKey: "unsaved-key" }, + }) + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx index 60ebd84053..3fade93ee5 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx @@ -8,9 +8,9 @@ const themes = [ name: "dark", bodyClass: "vscode-dark", themeId: "Default Dark Modern", - editorBackground: "#1e1e1e", - dropdownBackground: "#3c3c3c", - triggerBackground: "rgb(60, 60, 60)", + editorBackground: "#1f1f1f", + dropdownBackground: "#313131", + triggerBackground: "rgb(49, 49, 49)", }, { name: "light", diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx index 196d067755..61d3d76e4d 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx @@ -18,41 +18,47 @@ vi.mock("vscrui", () => ({ ), })) -// Mock the VSCodeTextField and VSCodeButton components -vi.mock("@vscode/webview-ui-toolkit/react", () => ({ - VSCodeTextField: ({ - children, - value, - onInput, - placeholder, - className, - style, - "data-testid": dataTestId, - ...rest - }: any) => { - return ( -
+// Mock only the controls we interact with in this spec; keep the rest real +// so newly-used toolkit exports (e.g. VSCodeLink) don't break this test. +vi.mock("@vscode/webview-ui-toolkit/react", async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + VSCodeTextField: ({ + children, + value, + onInput, + placeholder, + className, + style, + "data-testid": dataTestId, + ...rest + }: any) => { + return ( +
+ {children} + onInput && onInput(e)} + placeholder={placeholder} + data-testid={dataTestId} + {...rest} + /> +
+ ) + }, + VSCodeButton: ({ children, onClick, appearance, title }: any) => ( +
- ) - }, - VSCodeButton: ({ children, onClick, appearance, title }: any) => ( - - ), -})) + + ), + } +}) // Mock the translation hook vi.mock("@src/i18n/TranslationContext", () => ({ @@ -61,16 +67,23 @@ vi.mock("@src/i18n/TranslationContext", () => ({ }), })) -// Mock the UI components -vi.mock("@src/components/ui", () => ({ - Button: ({ children, onClick }: any) => , - StandardTooltip: ({ children, content }: any) =>
{children}
, -})) +// Mock only the pieces this spec needs to simplify interactions. +// Keep all other UI exports real so indirect dependencies (e.g. ModelPicker -> Popover) +// don't break when UI surface area evolves. +vi.mock("@src/components/ui", async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Button: ({ children, onClick }: any) => , + StandardTooltip: ({ children, content }: any) =>
{children}
, + } +}) // Mock other components const { mockModelPicker } = vi.hoisted(() => ({ mockModelPicker: vi.fn() })) -vi.mock("../../ModelPicker", () => ({ +vi.mock("@src/components/settings/ModelPicker", () => ({ ModelPicker: (props: any) => { mockModelPicker(props) return
Model Picker
@@ -83,7 +96,7 @@ vi.mock("../../R1FormatSetting", () => ({ const { mockThinkingBudget } = vi.hoisted(() => ({ mockThinkingBudget: vi.fn() })) -vi.mock("../../ThinkingBudget", () => ({ +vi.mock("@src/components/settings/ThinkingBudget", () => ({ ThinkingBudget: (props: any) => { mockThinkingBudget(props) return
Thinking Budget
diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.tsx index 17a98118bd..3cb085485a 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.tsx @@ -23,7 +23,7 @@ test("renders Azure OpenAI endpoint and deployment guidance in the VS Code dark .trim() }), ) - .toBe("#1e1e1e") + .toBe("#1f1f1f") await component.evaluate(async () => { await document.fonts.ready diff --git a/webview-ui/src/components/settings/providers/__tests__/Poe.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Poe.spec.tsx new file mode 100644 index 0000000000..b8c9f6e254 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Poe.spec.tsx @@ -0,0 +1,150 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" + +import { + allRouterModelsProvider, + providerIdentifiers, + type OrganizationAllowList, + type ProviderSettings, +} from "@roo-code/types" + +import { Poe } from "../Poe" + +const { mockUseExtensionState } = vi.hoisted(() => ({ + mockUseExtensionState: vi.fn(), +})) + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: mockUseExtensionState, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock("@src/components/ui", () => ({ + Button: ({ children, onClick, disabled }: React.ComponentProps<"button">) => ( + + ), +})) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: ({ onModelChange }: { onModelChange?: (modelId: string) => void }) => ( + + ), +})) + +describe("Poe", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + const setApiConfigurationField = vi.fn() + + const renderComponent = (apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.poe }) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + return render( + + + , + ) + } + + beforeEach(() => { + vi.clearAllMocks() + mockUseExtensionState.mockReturnValue({ routerModels: { [providerIdentifiers.poe]: {} } }) + }) + + it("shows the Poe refresh error returned by the extension", () => { + renderComponent({ apiProvider: providerIdentifiers.poe, poeApiKey: "test-key" }) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: providerIdentifiers.poe }, + error: "Poe authentication failed", + }, + }), + ) + }) + + expect(screen.getByText("Poe authentication failed")).toBeInTheDocument() + }) + + it("ignores failed refresh responses for another provider", () => { + renderComponent({ apiProvider: providerIdentifiers.poe, poeApiKey: "test-key" }) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: providerIdentifiers.openrouter }, + error: "OpenRouter authentication failed", + }, + }), + ) + }) + + expect(screen.queryByText("OpenRouter authentication failed")).not.toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.error")).not.toBeInTheDocument() + }) + + it("invalidates only the Poe and shared router-model caches after a successful refresh", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + render( + + + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", providerIdentifiers.poe], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", allRouterModelsProvider], + }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ["routerModels"] }) + }) + }) + + it("clears model-specific reasoning settings when the Poe model changes", () => { + renderComponent() + + fireEvent.click(screen.getByTestId("model-picker")) + + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", undefined) + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", undefined) + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", undefined) + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx new file mode 100644 index 0000000000..e355baa08c --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { providerIdentifiers, type OrganizationAllowList, type RouterModels } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +import { Unbound } from "../Unbound" +import { VercelAiGateway } from "../VercelAiGateway" + +const { modelPickerMock } = vi.hoisted(() => ({ modelPickerMock: vi.fn(() => null) })) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock("@src/components/ui", () => ({ + Button: ({ children, onClick }: React.ComponentProps<"button">) => , +})) + +vi.mock("../../ModelPicker", () => ({ ModelPicker: modelPickerMock })) +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ VSCodeButtonLink: () => null })) + +describe("provider model routing", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + + beforeEach(() => vi.clearAllMocks()) + + it("requests fresh Unbound models when the refresh button is clicked", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + + render( + , + ) + + fireEvent.click(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })) + + expect(postMessage).toHaveBeenCalledWith({ + type: "requestRouterModels", + values: { provider: providerIdentifiers.unbound, refresh: true }, + }) + }) + + it("passes Vercel AI Gateway models selected by its provider identifier to the model picker", () => { + const models = { "anthropic/claude": { contextWindow: 1, supportsPromptCache: false } } + const routerModels = Object.fromEntries( + Object.values(providerIdentifiers).map((provider) => [provider, {}]), + ) as RouterModels + routerModels[providerIdentifiers.vercelAiGateway] = models + + render( + , + ) + + expect(modelPickerMock).toHaveBeenCalledWith(expect.objectContaining({ models }), expect.anything()) + }) + + it("passes an empty model set when Vercel AI Gateway models are unavailable", () => { + render( + , + ) + + expect(modelPickerMock).toHaveBeenCalledWith(expect.objectContaining({ models: {} }), expect.anything()) + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/Requesty.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Requesty.spec.tsx new file mode 100644 index 0000000000..bb7d4e234f --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Requesty.spec.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { providerIdentifiers, type OrganizationAllowList } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +import { Requesty } from "../Requesty" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children }: { children: React.ReactNode }) =>
{children}
, + VSCodeCheckbox: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock("@src/components/ui", () => ({ + Button: ({ children, onClick }: React.ComponentProps<"button">) => ( + + ), +})) + +vi.mock("../../ModelPicker", () => ({ ModelPicker: () => null })) +vi.mock("../RequestyBalanceDisplay", () => ({ RequestyBalanceDisplay: () => null })) + +describe("Requesty", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + + it("uses the canonical Requesty identifier for OAuth and model refresh", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + + render( + , + ) + + const href = screen.getByRole("link").getAttribute("href") + expect(href).not.toBeNull() + const callbackUrl = new URL(href!).searchParams.get("callback_url") + expect(callbackUrl).not.toBeNull() + expect(new URL(callbackUrl!).pathname).toMatch(new RegExp(`/${providerIdentifiers.requesty}$`)) + + fireEvent.click(screen.getByTestId("refresh-button")) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestRouterModels", + values: { provider: providerIdentifiers.requesty, refresh: true }, + }) + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-dark.png index 87f8a1e061..a168e8656e 100644 Binary files a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-dark.png and b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-dark.png differ diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-light.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-light.png index 09bb7e5ee9..70d6eb1ad4 100644 Binary files a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-light.png and b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-light.png differ diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-compatible-azure-guidance-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-compatible-azure-guidance-dark.png index 11e4289cc7..e61ee8406d 100644 Binary files a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-compatible-azure-guidance-dark.png and b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-compatible-azure-guidance-dark.png differ diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 8d725efed2..12ea4cd786 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -26,6 +26,7 @@ export { Friendli } from "./Friendli" export { VercelAiGateway } from "./VercelAiGateway" export { OpenCodeGo } from "./OpenCodeGo" export { Kenari } from "./Kenari" +export { NanoGPT } from "./NanoGPT" export { ZooGateway } from "./ZooGateway" export { MiniMax } from "./MiniMax" export { Mimo } from "./Mimo" diff --git a/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts b/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts index 153f30b0e2..2bd91f19aa 100644 --- a/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts +++ b/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts @@ -1,10 +1,16 @@ -import { anthropicDefaultModelId, mainlandZAiDefaultModelId, providerIdentifiers } from "@roo-code/types" +import { + anthropicDefaultModelId, + mainlandZAiDefaultModelId, + nanoGptDefaultModelId, + providerIdentifiers, +} from "@roo-code/types" import { PROVIDER_SERVICE_CONFIG, PROVIDER_DEFAULT_MODEL_IDS, getProviderServiceConfig, getProviderModelConfig, + getProviderDocsSlug, getDefaultModelIdForProvider, getStaticModelsForProvider, isStaticModelProvider, @@ -110,6 +116,15 @@ describe("providerModelConfig", () => { expect(defaultId.length).toBeGreaterThan(0) }) + it("returns mainland default for Z.ai with china_api entrypoint", () => { + expect( + getDefaultModelIdForProvider("zai", { + apiProvider: "zai", + zaiApiLine: "china_api", + }), + ).toBe(mainlandZAiDefaultModelId) + }) + it("returns international default for Z.ai with international_coding entrypoint", () => { const defaultId = getDefaultModelIdForProvider("zai", { apiProvider: "zai", @@ -159,6 +174,19 @@ describe("providerModelConfig", () => { const config = getProviderModelConfig(providerIdentifiers.anthropic) expect(config).toEqual({ field: "apiModelId", default: anthropicDefaultModelId }) }) + + it("returns NanoGPT's dynamic model field and fallback", () => { + expect(getProviderModelConfig(providerIdentifiers.nanogpt)).toEqual({ + field: "nanoGptModelId", + default: nanoGptDefaultModelId, + }) + }) + }) + + describe("getProviderDocsSlug", () => { + it("uses NanoGPT's provider identifier as its external documentation slug", () => { + expect(getProviderDocsSlug(providerIdentifiers.nanogpt)).toBe("nanogpt") + }) }) describe("getStaticModelsForProvider", () => { @@ -177,6 +205,30 @@ describe("providerModelConfig", () => { const models = getStaticModelsForProvider("openrouter") expect(Object.keys(models).length).toBe(0) }) + + it("shows GLM-5.3 for international Z.ai API and Coding Plan entrypoints", () => { + const internationalCoding = getStaticModelsForProvider("zai", undefined, { + apiProvider: "zai", + zaiApiLine: "international_coding", + }) + const chinaCoding = getStaticModelsForProvider("zai", undefined, { + apiProvider: "zai", + zaiApiLine: "china_coding", + }) + const internationalApi = getStaticModelsForProvider("zai", undefined, { + apiProvider: "zai", + zaiApiLine: "international_api", + }) + const chinaApi = getStaticModelsForProvider("zai", undefined, { + apiProvider: "zai", + zaiApiLine: "china_api", + }) + + expect(internationalCoding).toHaveProperty("glm-5.3") + expect(chinaCoding).toHaveProperty("glm-5.3") + expect(internationalApi).toHaveProperty("glm-5.3") + expect(chinaApi).not.toHaveProperty("glm-5.3") + }) }) describe("isStaticModelProvider", () => { diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index eccbf7ba1d..7230e4cbe4 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -29,7 +29,10 @@ import { vercelAiGatewayDefaultModelId, opencodeGoDefaultModelId, kenariDefaultModelId, + nanoGptDefaultModelId, zooGatewayDefaultModelId, + zaiApiLineConfigs, + getZAiModels, } from "@roo-code/types" import { MODELS_BY_PROVIDER } from "../constants" @@ -97,9 +100,8 @@ export const getProviderServiceConfig = (provider: ProviderName): ProviderServic export const getDefaultModelIdForProvider = (provider: ProviderName, apiConfiguration?: ProviderSettings): string => { // Handle Z.ai's China/International entrypoint distinction if (provider === providerIdentifiers.zai && apiConfiguration) { - return apiConfiguration.zaiApiLine === "china_coding" - ? mainlandZAiDefaultModelId - : internationalZAiDefaultModelId + const apiLine = apiConfiguration.zaiApiLine ?? "international_coding" + return zaiApiLineConfigs[apiLine].isChina ? mainlandZAiDefaultModelId : internationalZAiDefaultModelId } return PROVIDER_DEFAULT_MODEL_IDS[provider] ?? "" @@ -143,6 +145,7 @@ const PROVIDER_MODEL_CONFIG: Partial> }, [providerIdentifiers.opencodeGo]: { field: "opencodeGoModelId", default: opencodeGoDefaultModelId }, [providerIdentifiers.kenari]: { field: "kenariModelId", default: kenariDefaultModelId }, + [providerIdentifiers.nanogpt]: { field: "nanoGptModelId", default: nanoGptDefaultModelId }, [providerIdentifiers.zooGateway]: { field: "zooGatewayModelId", default: zooGatewayDefaultModelId }, [providerIdentifiers.openai]: { field: "openAiModelId" }, [providerIdentifiers.ollama]: { field: "ollamaModelId" }, @@ -176,7 +179,12 @@ export function getProviderDocsSlug(provider: string) { export const getStaticModelsForProvider = ( provider: ProviderName, customArnLabel?: string, + apiConfiguration?: ProviderSettings, ): Record => { + if (provider === providerIdentifiers.zai) { + return getZAiModels(apiConfiguration?.zaiApiLine) + } + const models = MODELS_BY_PROVIDER[provider] ?? {} // Add custom-arn option for Bedrock diff --git a/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.fixture.tsx b/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.fixture.tsx new file mode 100644 index 0000000000..f5d6629c4d --- /dev/null +++ b/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.fixture.tsx @@ -0,0 +1,90 @@ +import React from "react" +import { Settings } from "lucide-react" + +import { IconButton } from "../../chat/IconButton" +import { Button } from "../button" +import { Checkbox } from "../checkbox" +import { Input } from "../input" +import { Progress } from "../progress" +import { RadioGroup, RadioGroupItem } from "../radio-group" +import { Slider } from "../slider" +import { Textarea } from "../textarea" + +export function AccessibilityContrastGallery() { + return ( +
+
+
+
+
+

+ New task +

+

+ Describe what Zoo Code should build or investigate. +

+
+ +
+