Merge main

This commit is contained in:
cte 2026-01-08 08:31:28 -08:00
commit 9a177cae28
107 changed files with 4414 additions and 248 deletions

View file

@ -7,5 +7,5 @@
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"ignore": ["@roo-code/cli"]
}

View file

@ -0,0 +1,188 @@
---
name: evals-context
description: Provides context about the Roo Code evals system structure in this monorepo. Use when tasks mention "evals", "evaluation", "eval runs", "eval exercises", or working with the evals infrastructure. Helps distinguish between the evals execution system (packages/evals, apps/web-evals) and the public website evals display page (apps/web-roo-code/src/app/evals).
---
# Evals Codebase Context
## When to Use This Skill
Use this skill when the task involves:
- Modifying or debugging the evals execution infrastructure
- Adding new eval exercises or languages
- Working with the evals web interface (apps/web-evals)
- Modifying the public evals display page on roocode.com
- Understanding where evals code lives in this monorepo
## When NOT to Use This Skill
Do NOT use this skill when:
- Working on unrelated parts of the codebase (extension, webview-ui, etc.)
- The task is purely about the VS Code extension's core functionality
- Working on the main website pages that don't involve evals
## Key Disambiguation: Two "Evals" Locations
This monorepo has **two distinct evals-related locations** that can cause confusion:
| Component | Path | Purpose |
| --------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- |
| **Evals Execution System** | `packages/evals/` | Core eval infrastructure: CLI, DB schema, Docker configs |
| **Evals Management UI** | `apps/web-evals/` | Next.js app for creating/monitoring eval runs (localhost:3446) |
| **Website Evals Page** | `apps/web-roo-code/src/app/evals/` | Public roocode.com page displaying eval results |
| **External Exercises Repo** | [Roo-Code-Evals](https://github.com/RooCodeInc/Roo-Code-Evals) | Actual coding exercises (NOT in this monorepo) |
## Directory Structure Reference
### `packages/evals/` - Core Evals Package
```
packages/evals/
├── ARCHITECTURE.md # Detailed architecture documentation
├── ADDING-EVALS.md # Guide for adding new exercises/languages
├── README.md # Setup and running instructions
├── docker-compose.yml # Container orchestration
├── Dockerfile.runner # Runner container definition
├── Dockerfile.web # Web app container
├── drizzle.config.ts # Database ORM config
├── src/
│ ├── index.ts # Package exports
│ ├── cli/ # CLI commands for running evals
│ │ ├── runEvals.ts # Orchestrates complete eval runs
│ │ ├── runTask.ts # Executes individual tasks in containers
│ │ ├── runUnitTest.ts # Validates task completion via tests
│ │ └── redis.ts # Redis pub/sub integration
│ ├── db/
│ │ ├── schema.ts # Database schema (runs, tasks)
│ │ ├── queries/ # Database query functions
│ │ └── migrations/ # SQL migrations
│ └── exercises/
│ └── index.ts # Exercise loading utilities
└── scripts/
└── setup.sh # Local macOS setup script
```
### `apps/web-evals/` - Evals Management Web App
```
apps/web-evals/
├── src/
│ ├── app/
│ │ ├── page.tsx # Home page (runs list)
│ │ ├── runs/
│ │ │ ├── new/ # Create new eval run
│ │ │ └── [id]/ # View specific run status
│ │ └── api/runs/ # SSE streaming endpoint
│ ├── actions/ # Server actions
│ │ ├── runs.ts # Run CRUD operations
│ │ ├── tasks.ts # Task queries
│ │ ├── exercises.ts # Exercise listing
│ │ └── heartbeat.ts # Controller health checks
│ ├── hooks/ # React hooks (SSE, models, etc.)
│ └── lib/ # Utilities and schemas
```
### `apps/web-roo-code/src/app/evals/` - Public Website Evals Page
```
apps/web-roo-code/src/app/evals/
├── page.tsx # Fetches and displays public eval results
├── evals.tsx # Main evals display component
├── plot.tsx # Visualization component
└── types.ts # EvalRun type (extends packages/evals types)
```
This page **displays** eval results on the public roocode.com website. It imports types from `@roo-code/evals` but does NOT run evals.
## Architecture Overview
The evals system is a distributed evaluation platform that runs AI coding tasks in isolated VS Code environments:
```
┌─────────────────────────────────────────────────────────────┐
│ Web App (apps/web-evals) ──────────────────────────────── │
│ │ │
│ ▼ │
│ PostgreSQL ◄────► Controller Container │
│ │ │ │
│ ▼ ▼ │
│ Redis ◄───► Runner Containers (1-25 parallel) │
└─────────────────────────────────────────────────────────────┘
```
**Key components:**
- **Controller**: Orchestrates eval runs, spawns runners, manages task queue (p-queue)
- **Runner**: Isolated Docker container with VS Code + Roo Code extension + language runtimes
- **Redis**: Pub/sub for real-time events (NOT task queuing)
- **PostgreSQL**: Stores runs, tasks, metrics
## Common Tasks Quick Reference
### Adding a New Eval Exercise
1. Add exercise to [Roo-Code-Evals](https://github.com/RooCodeInc/Roo-Code-Evals) repo (external)
2. See [`packages/evals/ADDING-EVALS.md`](packages/evals/ADDING-EVALS.md) for structure
### Modifying Eval CLI Behavior
Edit files in [`packages/evals/src/cli/`](packages/evals/src/cli/):
- [`runEvals.ts`](packages/evals/src/cli/runEvals.ts) - Run orchestration
- [`runTask.ts`](packages/evals/src/cli/runTask.ts) - Task execution
- [`runUnitTest.ts`](packages/evals/src/cli/runUnitTest.ts) - Test validation
### Modifying the Evals Web Interface
Edit files in [`apps/web-evals/src/`](apps/web-evals/src/):
- [`app/runs/new/new-run.tsx`](apps/web-evals/src/app/runs/new/new-run.tsx) - New run form
- [`actions/runs.ts`](apps/web-evals/src/actions/runs.ts) - Run server actions
### Modifying the Public Evals Display Page
Edit files in [`apps/web-roo-code/src/app/evals/`](apps/web-roo-code/src/app/evals/):
- [`evals.tsx`](apps/web-roo-code/src/app/evals/evals.tsx) - Display component
- [`plot.tsx`](apps/web-roo-code/src/app/evals/plot.tsx) - Charts
### Database Schema Changes
1. Edit [`packages/evals/src/db/schema.ts`](packages/evals/src/db/schema.ts)
2. Generate migration: `cd packages/evals && pnpm drizzle-kit generate`
3. Apply migration: `pnpm drizzle-kit migrate`
## Running Evals Locally
```bash
# From repo root
pnpm evals
# Opens web UI at http://localhost:3446
```
**Ports (defaults):**
- PostgreSQL: 5433
- Redis: 6380
- Web: 3446
## Testing
```bash
# packages/evals tests
cd packages/evals && npx vitest run
# apps/web-evals tests
cd apps/web-evals && npx vitest run
```
## Key Types/Exports from `@roo-code/evals`
The package exports are defined in [`packages/evals/src/index.ts`](packages/evals/src/index.ts):
- Database queries: `getRuns`, `getTasks`, `getTaskMetrics`, etc.
- Schema types: `Run`, `Task`, `TaskMetrics`
- Used by both `apps/web-evals` and `apps/web-roo-code`

View file

@ -1,5 +1,34 @@
# Roo Code Changelog
## 3.39.0
![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)

View file

@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { z } from "zod"
import { useQuery } from "@tanstack/react-query"
@ -48,6 +48,9 @@ import {
} from "@/lib/schemas"
import { cn } from "@/lib/utils"
import { loadRooLastModelSelection, saveRooLastModelSelection } from "@/lib/roo-last-model-selection"
import { normalizeCreateRunForSubmit } from "@/lib/normalize-create-run"
import { useOpenRouterModels } from "@/hooks/use-open-router-models"
import { useRooCodeCloudModels } from "@/hooks/use-roo-code-cloud-models"
@ -103,6 +106,8 @@ type ConfigSelection = {
export function NewRun() {
const router = useRouter()
const modelSelectionsByProviderRef = useRef<Record<string, ModelSelection[]>>({})
const modelValueByProviderRef = useRef<Record<string, string>>({})
const [provider, setModelSource] = useState<"roo" | "openrouter" | "other">("other")
const [executionMethod, setExecutionMethod] = useState<ExecutionMethod>("vscode")
@ -147,14 +152,43 @@ export function NewRun() {
})
const {
register,
setValue,
clearErrors,
watch,
getValues,
formState: { isSubmitting },
} = form
const [suite, settings] = watch(["suite", "settings", "concurrency"])
const selectedModelIds = useMemo(
() => modelSelections.map((s) => s.model).filter((m) => m.length > 0),
[modelSelections],
)
const applyModelIds = useCallback(
(modelIds: string[]) => {
const unique = Array.from(new Set(modelIds.map((m) => m.trim()).filter((m) => m.length > 0)))
if (unique.length === 0) {
setModelSelections([{ id: crypto.randomUUID(), model: "", popoverOpen: false }])
setValue("model", "")
return
}
setModelSelections(unique.map((model) => ({ id: crypto.randomUUID(), model, popoverOpen: false })))
setValue("model", unique[0] ?? "")
},
[setValue],
)
// Ensure the `exercises` field is registered so RHF always includes it in submit values.
useEffect(() => {
register("exercises")
}, [register])
// Load settings from localStorage on mount
useEffect(() => {
const savedConcurrency = localStorage.getItem("evals-concurrency")
@ -215,6 +249,51 @@ export function NewRun() {
}
}, [setValue])
// Track previous provider to detect switches
const [prevProvider, setPrevProvider] = useState(provider)
// Preserve selections per provider; avoids cross-contamination while keeping UX stable.
useEffect(() => {
if (provider === prevProvider) return
modelSelectionsByProviderRef.current[prevProvider] = modelSelections
modelValueByProviderRef.current[prevProvider] = getValues("model")
const nextModelSelections =
modelSelectionsByProviderRef.current[provider] ??
([{ id: crypto.randomUUID(), model: "", popoverOpen: false }] satisfies ModelSelection[])
setModelSelections(nextModelSelections)
const nextModelValue =
modelValueByProviderRef.current[provider] ??
nextModelSelections.find((s) => s.model.trim().length > 0)?.model ??
(provider === "other" && importedSettings && configSelections[0]?.configName
? (getModelId(importedSettings.apiConfigs[configSelections[0].configName] ?? {}) ?? "")
: "")
setValue("model", nextModelValue)
setPrevProvider(provider)
}, [provider, prevProvider, modelSelections, setValue, getValues, importedSettings, configSelections])
// When switching to Roo provider, restore last-used selection if current selection is empty
useEffect(() => {
if (provider !== "roo") return
if (selectedModelIds.length > 0) return
const last = loadRooLastModelSelection()
if (last.length > 0) {
applyModelIds(last)
}
}, [applyModelIds, provider, selectedModelIds.length])
// Persist last-used Roo provider model selection
useEffect(() => {
if (provider !== "roo") return
saveRooLastModelSelection(selectedModelIds)
}, [provider, selectedModelIds])
// Extract unique languages from exercises
const languages = useMemo(() => {
if (!exercises.data) {
return []
@ -337,7 +416,10 @@ export function NewRun() {
const onSubmit = useCallback(
async (values: CreateRun) => {
try {
if (provider === "roo" && !values.jobToken?.trim()) {
const baseValues = normalizeCreateRunForSubmit(values, selectedExercises, suite)
// Validate jobToken for Roo Code Cloud provider
if (provider === "roo" && !baseValues.jobToken?.trim()) {
toast.error("Roo Code Cloud Token is required")
return
}
@ -374,8 +456,7 @@ export function NewRun() {
await new Promise((resolve) => setTimeout(resolve, 20_000))
}
const runValues = { ...values }
runValues.executionMethod = executionMethod
const runValues = { ...baseValues }
if (provider === "openrouter") {
runValues.model = selection.model
@ -424,8 +505,9 @@ export function NewRun() {
}
},
[
suite,
selectedExercises,
provider,
executionMethod,
modelSelections,
configSelections,
importedSettings,

View file

@ -0,0 +1,65 @@
import { normalizeCreateRunForSubmit } from "../normalize-create-run"
describe("normalizeCreateRunForSubmit", () => {
it("uses selectedExercises for partial suite", () => {
const result = normalizeCreateRunForSubmit(
{
model: "roo/model-a",
description: "",
suite: "partial",
exercises: [],
settings: undefined,
concurrency: 1,
timeout: 5,
iterations: 1,
jobToken: "",
executionMethod: "vscode",
},
["js/foo", "py/bar"],
)
expect(result.suite).toBe("partial")
expect(result.exercises).toEqual(["js/foo", "py/bar"])
})
it("dedupes selectedExercises for partial suite", () => {
const result = normalizeCreateRunForSubmit(
{
model: "roo/model-a",
description: "",
suite: "partial",
exercises: [],
settings: undefined,
concurrency: 1,
timeout: 5,
iterations: 1,
jobToken: "",
executionMethod: "vscode",
},
["js/foo", "js/foo", "py/bar"],
)
expect(result.exercises).toEqual(["js/foo", "py/bar"])
})
it("clears exercises for full suite", () => {
const result = normalizeCreateRunForSubmit(
{
model: "roo/model-a",
description: "",
suite: "full",
exercises: ["js/foo"],
settings: undefined,
concurrency: 1,
timeout: 5,
iterations: 1,
jobToken: "",
executionMethod: "vscode",
},
["js/foo"],
)
expect(result.suite).toBe("full")
expect(result.exercises).toEqual([])
})
})

View file

@ -0,0 +1,78 @@
import {
loadRooLastModelSelection,
ROO_LAST_MODEL_SELECTION_KEY,
saveRooLastModelSelection,
} from "../roo-last-model-selection"
class LocalStorageMock implements Storage {
private store = new Map<string, string>()
get length(): number {
return this.store.size
}
clear(): void {
this.store.clear()
}
getItem(key: string): string | null {
return this.store.get(key) ?? null
}
key(index: number): string | null {
return Array.from(this.store.keys())[index] ?? null
}
removeItem(key: string): void {
this.store.delete(key)
}
setItem(key: string, value: string): void {
this.store.set(key, value)
}
}
beforeEach(() => {
Object.defineProperty(globalThis, "localStorage", {
value: new LocalStorageMock(),
configurable: true,
})
})
describe("roo-last-model-selection", () => {
it("saves and loads (deduped + trimmed)", () => {
saveRooLastModelSelection([" roo/model-a ", "roo/model-a", "roo/model-b"])
expect(loadRooLastModelSelection()).toEqual(["roo/model-a", "roo/model-b"])
})
it("ignores invalid JSON", () => {
localStorage.setItem(ROO_LAST_MODEL_SELECTION_KEY, "{this is not json")
expect(loadRooLastModelSelection()).toEqual([])
})
it("clears when empty", () => {
localStorage.setItem(ROO_LAST_MODEL_SELECTION_KEY, JSON.stringify(["roo/model-a"]))
saveRooLastModelSelection([])
expect(localStorage.getItem(ROO_LAST_MODEL_SELECTION_KEY)).toBeNull()
})
it("does not throw if localStorage access fails", () => {
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: () => {
throw new Error("blocked")
},
setItem: () => {
throw new Error("blocked")
},
removeItem: () => {
throw new Error("blocked")
},
},
configurable: true,
})
expect(() => loadRooLastModelSelection()).not.toThrow()
expect(() => saveRooLastModelSelection(["roo/model-a"])).not.toThrow()
})
})

View file

@ -0,0 +1,20 @@
import type { CreateRun } from "./schemas"
/**
* The New Run UI keeps exercise selection in component state.
* This normalizer ensures we submit the *visible/selected* exercises when suite is partial.
*/
export function normalizeCreateRunForSubmit(
values: CreateRun,
selectedExercises: string[],
suiteOverride?: CreateRun["suite"],
): CreateRun {
const suite = suiteOverride ?? values.suite
const normalizedSelectedExercises = Array.from(new Set(selectedExercises))
return {
...values,
suite,
exercises: suite === "partial" ? normalizedSelectedExercises : [],
}
}

View file

@ -0,0 +1,76 @@
import { z } from "zod"
export const ROO_LAST_MODEL_SELECTION_KEY = "evals-roo-last-model-selection"
const modelIdListSchema = z.array(z.string())
function hasLocalStorage(): boolean {
try {
return typeof localStorage !== "undefined"
} catch {
return false
}
}
function safeGetItem(key: string): string | null {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function safeSetItem(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
// ignore
}
}
function safeRemoveItem(key: string): void {
try {
localStorage.removeItem(key)
} catch {
// ignore
}
}
function tryParseJson(raw: string | null): unknown {
if (raw === null) return undefined
try {
return JSON.parse(raw)
} catch {
return undefined
}
}
function normalizeModelIds(modelIds: string[]): string[] {
const unique = new Set<string>()
for (const id of modelIds) {
const trimmed = id.trim()
if (trimmed) unique.add(trimmed)
}
return Array.from(unique)
}
export function loadRooLastModelSelection(): string[] {
if (!hasLocalStorage()) return []
const parsed = modelIdListSchema.safeParse(tryParseJson(safeGetItem(ROO_LAST_MODEL_SELECTION_KEY)))
if (!parsed.success) return []
return normalizeModelIds(parsed.data)
}
export function saveRooLastModelSelection(modelIds: string[]): void {
if (!hasLocalStorage()) return
const normalized = normalizeModelIds(modelIds)
if (normalized.length === 0) {
safeRemoveItem(ROO_LAST_MODEL_SELECTION_KEY)
return
}
safeSetItem(ROO_LAST_MODEL_SELECTION_KEY, JSON.stringify(normalized))
}

View file

@ -39,7 +39,7 @@ export const features: Feature[] = [
icon: CheckCheck,
title: "Granular auto-approval",
description:
"Control each action and make Roo as autonomous as you want as you build confidence. Or go YOLO and let it rip.",
"Control each action and make Roo as autonomous as you want as you build confidence. Or go BRRR and let it rip.",
},
{
icon: Boxes,

View file

@ -29,6 +29,7 @@ export const historyItemSchema = z.object({
* This ensures task resumption works correctly even when NTC settings change.
*/
toolProtocol: z.enum(["xml", "native"]).optional(),
apiConfigName: z.string().optional(), // Provider profile name for sticky profile feature
status: z.enum(["active", "completed", "delegated"]).optional(),
delegatedToId: z.string().optional(), // Last child this parent delegated to
childIds: z.array(z.string()).optional(), // All children spawned by this task

View file

@ -264,39 +264,6 @@ export const bedrockModels = {
inputPrice: 0.25,
outputPrice: 1.25,
},
"anthropic.claude-2-1-v1:0": {
maxTokens: 4096,
contextWindow: 100_000,
supportsImages: false,
supportsPromptCache: false,
supportsNativeTools: true,
defaultToolProtocol: "native",
inputPrice: 8.0,
outputPrice: 24.0,
description: "Claude 2.1",
},
"anthropic.claude-2-0-v1:0": {
maxTokens: 4096,
contextWindow: 100_000,
supportsImages: false,
supportsPromptCache: false,
supportsNativeTools: true,
defaultToolProtocol: "native",
inputPrice: 8.0,
outputPrice: 24.0,
description: "Claude 2.0",
},
"anthropic.claude-instant-v1:0": {
maxTokens: 4096,
contextWindow: 100_000,
supportsImages: false,
supportsPromptCache: false,
supportsNativeTools: true,
defaultToolProtocol: "native",
inputPrice: 0.8,
outputPrice: 2.4,
description: "Claude Instant",
},
"deepseek.r1-v1:0": {
maxTokens: 32_768,
contextWindow: 128_000,

View file

@ -15,7 +15,19 @@ export const cerebrasModels = {
defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Highly intelligent general purpose model with up to 1,000 tokens/s",
description: "Fast general-purpose model on Cerebras (up to 1,000 tokens/s). To be deprecated soon.",
},
"zai-glm-4.7": {
maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront)
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
supportsNativeTools: true,
defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description:
"Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.",
},
"qwen-3-235b-a22b-instruct-2507": {
maxTokens: 16384, // Conservative default to avoid premature rate limiting

View file

@ -3,6 +3,7 @@ import type { ModelInfo } from "../model.js"
export type FireworksModelId =
| "accounts/fireworks/models/kimi-k2-instruct"
| "accounts/fireworks/models/kimi-k2-instruct-0905"
| "accounts/fireworks/models/kimi-k2-thinking"
| "accounts/fireworks/models/minimax-m2"
| "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507"
| "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct"
@ -43,6 +44,21 @@ export const fireworksModels = {
description:
"Kimi K2 is a state-of-the-art mixture-of-experts (MoE) language model with 32 billion activated parameters and 1 trillion total parameters. Trained with the Muon optimizer, Kimi K2 achieves exceptional performance across frontier knowledge, reasoning, and coding tasks while being meticulously optimized for agentic capabilities.",
},
"accounts/fireworks/models/kimi-k2-thinking": {
maxTokens: 16000,
contextWindow: 256000,
supportsImages: false,
supportsPromptCache: true,
supportsNativeTools: true,
supportsTemperature: true,
preserveReasoning: true,
defaultTemperature: 1.0,
inputPrice: 0.6,
outputPrice: 2.5,
cacheReadsPrice: 0.15,
description:
"The kimi-k2-thinking model is a general-purpose agentic reasoning model developed by Moonshot AI. Thanks to its strength in deep reasoning and multi-turn tool use, it can solve even the hardest problems.",
},
"accounts/fireworks/models/minimax-m2": {
maxTokens: 4096,
contextWindow: 204800,

74
pnpm-lock.yaml generated
View file

@ -817,6 +817,9 @@ importers:
get-folder-size:
specifier: ^5.0.0
version: 5.0.0
global-agent:
specifier: ^3.0.0
version: 3.0.0
google-auth-library:
specifier: ^9.15.1
version: 9.15.1
@ -943,6 +946,9 @@ importers:
turndown:
specifier: ^7.2.0
version: 7.2.0
undici:
specifier: '>=5.29.0'
version: 6.21.3
uuid:
specifier: ^11.1.0
version: 11.1.0
@ -4808,6 +4814,10 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
boolean@3.2.0:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
bowser@2.11.0:
resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==}
@ -5618,6 +5628,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
detect-node@2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@ -5937,6 +5950,9 @@ packages:
es-toolkit@1.43.0:
resolution: {integrity: sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==}
es6-error@4.1.1:
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
esbuild-register@3.6.0:
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
peerDependencies:
@ -6516,6 +6532,10 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
global-agent@3.0.0:
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
engines: {node: '>=10.0'}
globals@11.12.0:
resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
engines: {node: '>=4'}
@ -7672,6 +7692,10 @@ packages:
engines: {node: '>= 20'}
hasBin: true
matcher@3.0.0:
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@ -9036,6 +9060,10 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
roarr@2.15.4:
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
engines: {node: '>=8.0'}
robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
@ -9160,6 +9188,10 @@ packages:
resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==}
engines: {node: '>=18'}
serialize-error@7.0.1:
resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
engines: {node: '>=10'}
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
@ -9863,6 +9895,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
type-fest@0.13.1:
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
engines: {node: '>=10'}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
@ -14850,6 +14886,8 @@ snapshots:
boolbase@1.0.0: {}
boolean@3.2.0: {}
bowser@2.11.0: {}
brace-expansion@2.0.2:
@ -15660,6 +15698,8 @@ snapshots:
detect-node-es@1.1.0: {}
detect-node@2.1.0: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
@ -15954,6 +15994,8 @@ snapshots:
es-toolkit@1.43.0: {}
es6-error@4.1.1: {}
esbuild-register@3.6.0(esbuild@0.25.9):
dependencies:
debug: 4.4.1(supports-color@8.1.1)
@ -16715,6 +16757,15 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 2.0.0
global-agent@3.0.0:
dependencies:
boolean: 3.2.0
es6-error: 4.1.1
matcher: 3.0.0
roarr: 2.15.4
semver: 7.7.3
serialize-error: 7.0.1
globals@11.12.0: {}
globals@14.0.0: {}
@ -17525,7 +17576,7 @@ snapshots:
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.2
semver: 7.7.3
jsx-ast-utils@3.3.5:
dependencies:
@ -17949,6 +18000,10 @@ snapshots:
marked@16.2.0: {}
matcher@3.0.0:
dependencies:
escape-string-regexp: 4.0.0
math-intrinsics@1.1.0: {}
mdast-util-definitions@4.0.0:
@ -18591,7 +18646,7 @@ snapshots:
node-abi@3.75.0:
dependencies:
semver: 7.7.2
semver: 7.7.3
optional: true
node-addon-api@4.3.0:
@ -19718,6 +19773,15 @@ snapshots:
glob: 11.1.0
package-json-from-dist: 1.0.1
roarr@2.15.4:
dependencies:
boolean: 3.2.0
detect-node: 2.1.0
globalthis: 1.0.4
json-stringify-safe: 5.0.1
semver-compare: 1.0.0
sprintf-js: 1.1.3
robust-predicates@3.0.2: {}
rollup@4.40.2:
@ -19881,6 +19945,10 @@ snapshots:
dependencies:
type-fest: 4.41.0
serialize-error@7.0.1:
dependencies:
type-fest: 0.13.1
serialize-javascript@6.0.2:
dependencies:
randombytes: 2.1.0
@ -20657,6 +20725,8 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
type-fest@0.13.1: {}
type-fest@4.41.0: {}
type-is@2.0.1:

BIN
releases/3.39.0-release.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View file

@ -122,7 +122,7 @@ describe("AwsBedrockHandler with invokedModelId", () => {
trace: {
promptRouter: {
invokedModelId:
"arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-2-1-v1:0",
"arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-3-opus-20240229-v1:0",
usage: {
inputTokens: 150,
outputTokens: 250,
@ -162,12 +162,12 @@ describe("AwsBedrockHandler with invokedModelId", () => {
}
// Verify that getModelById was called with the id, not the full arn
expect(getModelByIdSpy).toHaveBeenCalledWith("anthropic.claude-2-1-v1:0", "inference-profile")
expect(getModelByIdSpy).toHaveBeenCalledWith("anthropic.claude-3-opus-20240229-v1:0", "inference-profile")
// Verify that getModel returns the updated model info
const costModel = handler.getModel()
//expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20240620-v1:0")
expect(costModel.info.inputPrice).toBe(8)
expect(costModel.info.inputPrice).toBe(15)
// Verify that a usage event was emitted after updating the costModelConfig
const usageEvents = events.filter((event) => event.type === "usage")

View file

@ -115,6 +115,31 @@ describe("FireworksHandler", () => {
)
})
it("should return Kimi K2 Thinking model with correct configuration", () => {
const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking"
const handlerWithModel = new FireworksHandler({
apiModelId: testModelId,
fireworksApiKey: "test-fireworks-api-key",
})
const model = handlerWithModel.getModel()
expect(model.id).toBe(testModelId)
expect(model.info).toEqual(
expect.objectContaining({
maxTokens: 16000,
contextWindow: 256000,
supportsImages: false,
supportsPromptCache: true,
supportsNativeTools: true,
supportsTemperature: true,
preserveReasoning: true,
defaultTemperature: 1.0,
inputPrice: 0.6,
outputPrice: 2.5,
cacheReadsPrice: 0.15,
}),
)
})
it("should return MiniMax M2 model with correct configuration", () => {
const testModelId: FireworksModelId = "accounts/fireworks/models/minimax-m2"
const handlerWithModel = new FireworksHandler({
@ -424,16 +449,85 @@ describe("FireworksHandler", () => {
)
})
it("should use default temperature of 0.5", () => {
const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct"
it("should use provider default temperature of 0.5 for models without defaultTemperature", async () => {
const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct"
const handlerWithModel = new FireworksHandler({
apiModelId: testModelId,
apiModelId: modelId,
fireworksApiKey: "test-fireworks-api-key",
})
const model = handlerWithModel.getModel()
// The temperature is set in the constructor as defaultTemperature: 0.5
// This test verifies the handler is configured with the correct default temperature
expect(handlerWithModel).toBeDefined()
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
}))
const messageGenerator = handlerWithModel.createMessage("system", [])
await messageGenerator.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.5,
}),
undefined,
)
})
it("should use model defaultTemperature (1.0) over provider default (0.5) for kimi-k2-thinking", async () => {
const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking"
const handlerWithModel = new FireworksHandler({
apiModelId: modelId,
fireworksApiKey: "test-fireworks-api-key",
})
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
}))
const messageGenerator = handlerWithModel.createMessage("system", [])
await messageGenerator.next()
// Model's defaultTemperature (1.0) should take precedence over provider's default (0.5)
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 1.0,
}),
undefined,
)
})
it("should use user-specified temperature over model and provider defaults", async () => {
const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking"
const handlerWithModel = new FireworksHandler({
apiModelId: modelId,
fireworksApiKey: "test-fireworks-api-key",
modelTemperature: 0.7,
})
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
}))
const messageGenerator = handlerWithModel.createMessage("system", [])
await messageGenerator.next()
// User-specified temperature should take precedence over everything
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.7,
}),
undefined,
)
})
it("should handle empty response in completePrompt", async () => {

View file

@ -84,7 +84,7 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
format: "openai",
}) ?? undefined
const temperature = this.options.modelTemperature ?? this.defaultTemperature
const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model,

View file

@ -77,6 +77,18 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
say: vi.fn().mockResolvedValue(undefined),
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
}
// Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
const existingResult = mockTask.userMessageContent.find(
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
)
if (existingResult) {
return false
}
mockTask.userMessageContent.push(toolResult)
return true
})
})
describe("Custom tool usage recording", () => {

View file

@ -60,6 +60,18 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
say: vi.fn().mockResolvedValue(undefined),
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
}
// Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
const existingResult = mockTask.userMessageContent.find(
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
)
if (existingResult) {
return false
}
mockTask.userMessageContent.push(toolResult)
return true
})
})
it("should preserve images in tool_result for native protocol", async () => {

View file

@ -59,6 +59,18 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
say: vi.fn().mockResolvedValue(undefined),
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
}
// Add pushToolResultToUserContent method after mockTask is created so 'this' binds correctly
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
const existingResult = mockTask.userMessageContent.find(
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
)
if (existingResult) {
return false
}
mockTask.userMessageContent.push(toolResult)
return true
})
})
it("should return error for unknown tool in native protocol", async () => {

View file

@ -115,12 +115,12 @@ export async function presentAssistantMessage(cline: Task) {
: `MCP tool ${mcpBlock.name} was interrupted and not executed due to user rejecting a previous tool.`
if (toolCallId) {
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
}
break
}
@ -130,12 +130,12 @@ export async function presentAssistantMessage(cline: Task) {
const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.`
if (toolCallId) {
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
}
break
}
@ -167,11 +167,11 @@ export async function presentAssistantMessage(cline: Task) {
}
if (toolCallId) {
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: resultContent,
} as Anthropic.ToolResultBlockParam)
})
if (imageBlocks.length > 0) {
cline.userMessageContent.push(...imageBlocks)
@ -446,12 +446,12 @@ export async function presentAssistantMessage(cline: Task) {
if (toolCallId) {
// Native protocol: MUST send tool_result for every tool_use
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
} else {
// XML protocol: send as text
cline.userMessageContent.push({
@ -471,12 +471,12 @@ export async function presentAssistantMessage(cline: Task) {
if (toolCallId) {
// Native protocol: MUST send tool_result for every tool_use
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
} else {
// XML protocol: send as text
cline.userMessageContent.push({
@ -530,11 +530,11 @@ export async function presentAssistantMessage(cline: Task) {
}
// Add tool_result with text content only
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: resultContent,
} as Anthropic.ToolResultBlockParam)
})
// Add image blocks separately after tool_result
if (imageBlocks.length > 0) {
@ -735,12 +735,12 @@ export async function presentAssistantMessage(cline: Task) {
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
// For native protocol, push tool_result directly without setting didAlreadyUseTool
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: typeof errorContent === "string" ? errorContent : "(validation error)",
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
} else {
// For XML protocol, use the standard pushToolResult
pushToolResult(errorContent)
@ -1110,12 +1110,12 @@ export async function presentAssistantMessage(cline: Task) {
// Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: formatResponse.toolError(errorMessage, toolProtocol),
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
} else {
pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
}

View file

@ -0,0 +1,193 @@
import * as path from "path"
import { resolveImageMentions } from "../resolveImageMentions"
vi.mock("../../tools/helpers/imageHelpers", () => ({
isSupportedImageFormat: vi.fn((ext: string) =>
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico", ".tiff", ".tif", ".avif"].includes(
ext.toLowerCase(),
),
),
readImageAsDataUrlWithBuffer: vi.fn(),
validateImageForProcessing: vi.fn(),
ImageMemoryTracker: vi.fn().mockImplementation(() => ({
getTotalMemoryUsed: vi.fn().mockReturnValue(0),
addMemoryUsage: vi.fn(),
})),
DEFAULT_MAX_IMAGE_FILE_SIZE_MB: 5,
DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB: 20,
}))
import { validateImageForProcessing, readImageAsDataUrlWithBuffer } from "../../tools/helpers/imageHelpers"
const mockReadImageAsDataUrl = vi.mocked(readImageAsDataUrlWithBuffer)
const mockValidateImage = vi.mocked(validateImageForProcessing)
describe("resolveImageMentions", () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: validation passes
mockValidateImage.mockResolvedValue({ isValid: true, sizeInMB: 0.1 })
})
it("should append a data URL when a local png mention is present", async () => {
const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}`
mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") })
const result = await resolveImageMentions({
text: "Please look at @/assets/cat.png",
images: [],
cwd: "/workspace",
})
expect(mockValidateImage).toHaveBeenCalled()
expect(mockReadImageAsDataUrl).toHaveBeenCalledWith(path.resolve("/workspace", "assets/cat.png"))
expect(result.text).toBe("Please look at @/assets/cat.png")
expect(result.images).toEqual([dataUrl])
})
it("should support gif images (matching read_file)", async () => {
const dataUrl = `data:image/gif;base64,${Buffer.from("gif-bytes").toString("base64")}`
mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("gif-bytes") })
const result = await resolveImageMentions({
text: "See @/animation.gif",
images: [],
cwd: "/workspace",
})
expect(result.images).toEqual([dataUrl])
})
it("should support svg images (matching read_file)", async () => {
const dataUrl = `data:image/svg+xml;base64,${Buffer.from("svg-bytes").toString("base64")}`
mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("svg-bytes") })
const result = await resolveImageMentions({
text: "See @/icon.svg",
images: [],
cwd: "/workspace",
})
expect(result.images).toEqual([dataUrl])
})
it("should ignore non-image mentions", async () => {
const result = await resolveImageMentions({
text: "See @/src/index.ts",
images: [],
cwd: "/workspace",
})
expect(mockReadImageAsDataUrl).not.toHaveBeenCalled()
expect(result.images).toEqual([])
})
it("should skip unreadable files (fail-soft)", async () => {
mockReadImageAsDataUrl.mockRejectedValue(new Error("ENOENT"))
const result = await resolveImageMentions({
text: "See @/missing.webp",
images: [],
cwd: "/workspace",
})
expect(result.images).toEqual([])
})
it("should respect rooIgnoreController", async () => {
const dataUrl = `data:image/jpeg;base64,${Buffer.from("jpg-bytes").toString("base64")}`
mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("jpg-bytes") })
const rooIgnoreController = {
validateAccess: vi.fn().mockReturnValue(false),
}
const result = await resolveImageMentions({
text: "See @/secret.jpg",
images: [],
cwd: "/workspace",
rooIgnoreController,
})
expect(rooIgnoreController.validateAccess).toHaveBeenCalledWith("secret.jpg")
expect(mockReadImageAsDataUrl).not.toHaveBeenCalled()
expect(result.images).toEqual([])
})
it("should dedupe when mention repeats", async () => {
const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}`
mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") })
const result = await resolveImageMentions({
text: "@/a.png and again @/a.png",
images: [],
cwd: "/workspace",
})
expect(result.images).toHaveLength(1)
})
it("should skip images when supportsImages is false", async () => {
const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}`
mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") })
const result = await resolveImageMentions({
text: "See @/cat.png",
images: [],
cwd: "/workspace",
supportsImages: false,
})
expect(mockReadImageAsDataUrl).not.toHaveBeenCalled()
expect(result.images).toEqual([])
})
it("should skip images that exceed size limits", async () => {
mockValidateImage.mockResolvedValue({
isValid: false,
reason: "size_limit",
notice: "Image too large",
})
const result = await resolveImageMentions({
text: "See @/huge.png",
images: [],
cwd: "/workspace",
})
expect(mockValidateImage).toHaveBeenCalled()
expect(mockReadImageAsDataUrl).not.toHaveBeenCalled()
expect(result.images).toEqual([])
})
it("should skip images that would exceed memory limit", async () => {
mockValidateImage.mockResolvedValue({
isValid: false,
reason: "memory_limit",
notice: "Would exceed memory limit",
})
const result = await resolveImageMentions({
text: "See @/large.png",
images: [],
cwd: "/workspace",
})
expect(result.images).toEqual([])
})
it("should pass custom size limits to validation", async () => {
const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}`
mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") })
await resolveImageMentions({
text: "See @/cat.png",
images: [],
cwd: "/workspace",
maxImageFileSize: 10,
maxTotalImageSize: 50,
})
expect(mockValidateImage).toHaveBeenCalledWith(expect.any(String), true, 10, 50, 0)
})
})

View file

@ -284,7 +284,13 @@ async function getFileOrFolderContent(
const stats = await fs.stat(absPath)
if (stats.isFile()) {
if (rooIgnoreController && !rooIgnoreController.validateAccess(absPath)) {
// Avoid trying to include image binary content as text context.
// Image mentions are handled separately via image attachment flow.
const isBinary = await isBinaryFile(absPath).catch(() => false)
if (isBinary) {
return `(Binary file ${mentionPath} omitted)`
}
if (rooIgnoreController && !rooIgnoreController.validateAccess(unescapedPath)) {
return `(File ${mentionPath} is ignored by .rooignore)`
}
try {

View file

@ -0,0 +1,145 @@
import * as path from "path"
import { mentionRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"
import {
isSupportedImageFormat,
readImageAsDataUrlWithBuffer,
validateImageForProcessing,
ImageMemoryTracker,
DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
} from "../tools/helpers/imageHelpers"
const MAX_IMAGES_PER_MESSAGE = 20
export interface ResolveImageMentionsOptions {
text: string
images?: string[]
cwd: string
rooIgnoreController?: { validateAccess: (filePath: string) => boolean }
/** Whether the current model supports images. Defaults to true. */
supportsImages?: boolean
/** Maximum size per image file in MB. Defaults to 5MB. */
maxImageFileSize?: number
/** Maximum total size of all images in MB. Defaults to 20MB. */
maxTotalImageSize?: number
}
export interface ResolveImageMentionsResult {
text: string
images: string[]
}
function isPathWithinCwd(absPath: string, cwd: string): boolean {
const rel = path.relative(cwd, absPath)
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
}
function dedupePreserveOrder(values: string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const v of values) {
if (seen.has(v)) continue
seen.add(v)
result.push(v)
}
return result
}
/**
* Resolves local image file mentions like `@/path/to/image.png` found in `text` into `data:image/...;base64,...`
* and appends them to the outgoing `images` array.
*
* Behavior matches the read_file tool:
* - Supports the same image formats: png, jpg, jpeg, gif, webp, svg, bmp, ico, tiff, avif
* - Respects per-file size limits (default 5MB)
* - Respects total memory limits (default 20MB)
* - Skips images if model doesn't support them
* - Respects `.rooignore` via `rooIgnoreController.validateAccess` when provided
*/
export async function resolveImageMentions({
text,
images,
cwd,
rooIgnoreController,
supportsImages = true,
maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
}: ResolveImageMentionsOptions): Promise<ResolveImageMentionsResult> {
const existingImages = Array.isArray(images) ? images : []
if (existingImages.length >= MAX_IMAGES_PER_MESSAGE) {
return { text, images: existingImages.slice(0, MAX_IMAGES_PER_MESSAGE) }
}
// If model doesn't support images, skip image processing entirely
if (!supportsImages) {
return { text, images: existingImages }
}
const mentions = Array.from(text.matchAll(mentionRegexGlobal))
.map((m) => m[1])
.filter(Boolean)
if (mentions.length === 0) {
return { text, images: existingImages }
}
const imageMentions = mentions.filter((mention) => {
if (!mention.startsWith("/")) return false
const relPath = unescapeSpaces(mention.slice(1))
const ext = path.extname(relPath).toLowerCase()
return isSupportedImageFormat(ext)
})
if (imageMentions.length === 0) {
return { text, images: existingImages }
}
const imageMemoryTracker = new ImageMemoryTracker()
const newImages: string[] = []
for (const mention of imageMentions) {
if (existingImages.length + newImages.length >= MAX_IMAGES_PER_MESSAGE) {
break
}
const relPath = unescapeSpaces(mention.slice(1))
const absPath = path.resolve(cwd, relPath)
if (!isPathWithinCwd(absPath, cwd)) {
continue
}
if (rooIgnoreController && !rooIgnoreController.validateAccess(relPath)) {
continue
}
// Validate image size limits (matches read_file behavior)
try {
const validationResult = await validateImageForProcessing(
absPath,
supportsImages,
maxImageFileSize,
maxTotalImageSize,
imageMemoryTracker.getTotalMemoryUsed(),
)
if (!validationResult.isValid) {
// Skip this image due to size/memory limits, but continue processing others
continue
}
const { dataUrl } = await readImageAsDataUrlWithBuffer(absPath)
newImages.push(dataUrl)
// Track memory usage
if (validationResult.sizeInMB) {
imageMemoryTracker.addMemoryUsage(validationResult.sizeInMB)
}
} catch {
// Fail-soft: skip unreadable/missing files.
continue
}
}
const merged = dedupePreserveOrder([...existingImages, ...newImages]).slice(0, MAX_IMAGES_PER_MESSAGE)
return { text, images: merged }
}

View file

@ -1,7 +1,8 @@
import { addCustomInstructions } from "../sections/custom-instructions"
import { getCapabilitiesSection } from "../sections/capabilities"
import { getRulesSection } from "../sections/rules"
import { getRulesSection, getCommandChainOperator } from "../sections/rules"
import { McpHub } from "../../../services/mcp/McpHub"
import * as shellUtils from "../../../utils/shell"
describe("addCustomInstructions", () => {
it("adds vscode language to custom instructions", async () => {
@ -114,3 +115,117 @@ describe("getRulesSection", () => {
expect(result).not.toContain("Never reveal the vendor or company")
})
})
describe("getCommandChainOperator", () => {
it("returns && for bash shell", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash")
expect(getCommandChainOperator()).toBe("&&")
})
it("returns && for zsh shell", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh")
expect(getCommandChainOperator()).toBe("&&")
})
it("returns ; for PowerShell", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue(
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
)
expect(getCommandChainOperator()).toBe(";")
})
it("returns ; for PowerShell Core (pwsh)", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
expect(getCommandChainOperator()).toBe(";")
})
it("returns && for cmd.exe", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe")
expect(getCommandChainOperator()).toBe("&&")
})
it("returns && for Git Bash on Windows", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Program Files\\Git\\bin\\bash.exe")
expect(getCommandChainOperator()).toBe("&&")
})
it("returns && for WSL bash", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash")
expect(getCommandChainOperator()).toBe("&&")
})
})
describe("getRulesSection shell-aware command chaining", () => {
const cwd = "/test/path"
afterEach(() => {
vi.restoreAllMocks()
})
it("uses && for Unix shells in command chaining example", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash")
const result = getRulesSection(cwd)
expect(result).toContain("cd (path to project) && (command")
expect(result).not.toContain("cd (path to project) ; (command")
expect(result).not.toContain("cd (path to project) & (command")
})
it("uses ; for PowerShell in command chaining example", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue(
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
)
const result = getRulesSection(cwd)
expect(result).toContain("cd (path to project) ; (command")
expect(result).toContain("Note: Using `;` for PowerShell command chaining")
})
it("uses && for cmd.exe in command chaining example", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe")
const result = getRulesSection(cwd)
expect(result).toContain("cd (path to project) && (command")
expect(result).toContain("Note: Using `&&` for cmd.exe command chaining")
})
it("includes Unix utility guidance for PowerShell", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue(
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
)
const result = getRulesSection(cwd)
expect(result).toContain("IMPORTANT: When using PowerShell, avoid Unix-specific utilities")
expect(result).toContain("`sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`")
expect(result).toContain("`Select-String` for grep")
expect(result).toContain("`Get-Content` for cat")
expect(result).toContain("PowerShell's `-replace` operator")
})
it("includes Unix utility guidance for cmd.exe", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe")
const result = getRulesSection(cwd)
expect(result).toContain("IMPORTANT: When using cmd.exe, avoid Unix-specific utilities")
expect(result).toContain("`sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`")
expect(result).toContain("`type` for cat")
expect(result).toContain("`del` for rm")
expect(result).toContain("`find`/`findstr` for grep")
})
it("does not include Unix utility guidance for Unix shells", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash")
const result = getRulesSection(cwd)
expect(result).not.toContain("IMPORTANT: When using PowerShell")
expect(result).not.toContain("IMPORTANT: When using cmd.exe")
expect(result).not.toContain("`Select-String` for grep")
})
it("does not include note for Unix shells", () => {
vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh")
const result = getRulesSection(cwd)
expect(result).not.toContain("Note: Using")
})
})

View file

@ -0,0 +1,66 @@
import os from "os"
// Mock the modules - must be hoisted before imports
vi.mock("os-name", () => ({
default: vi.fn(),
}))
vi.mock("../../../../utils/shell", () => ({
getShell: vi.fn(() => "/bin/bash"),
}))
import { getSystemInfoSection } from "../system-info"
import osName from "os-name"
const mockOsName = osName as unknown as ReturnType<typeof vi.fn>
describe("getSystemInfoSection", () => {
const mockCwd = "/test/workspace"
const mockHomeDir = "/home/user"
beforeEach(() => {
vi.spyOn(os, "homedir").mockReturnValue(mockHomeDir)
vi.spyOn(os, "platform").mockReturnValue("linux" as any)
vi.spyOn(os, "release").mockReturnValue("5.15.0")
})
afterEach(() => {
vi.clearAllMocks()
})
it("should return system info with os-name when available", () => {
mockOsName.mockReturnValue("Ubuntu 22.04")
const result = getSystemInfoSection(mockCwd)
expect(result).toContain("Operating System: Ubuntu 22.04")
expect(result).toContain("Default Shell: /bin/bash")
expect(result).toContain(`Home Directory: ${mockHomeDir}`)
expect(result).toContain(`Current Workspace Directory: ${mockCwd}`)
})
it("should fallback to platform and release when os-name throws error", () => {
mockOsName.mockImplementation(() => {
throw new Error("Command failed with ENOENT: powershell")
})
const result = getSystemInfoSection(mockCwd)
expect(result).toContain("Operating System: linux 5.15.0")
expect(result).toContain("Default Shell: /bin/bash")
expect(result).toContain(`Home Directory: ${mockHomeDir}`)
expect(result).toContain(`Current Workspace Directory: ${mockCwd}`)
})
it("should handle Windows platform in fallback", () => {
mockOsName.mockImplementation(() => {
throw new Error("Command failed with ENOENT: powershell")
})
vi.spyOn(os, "platform").mockReturnValue("win32" as any)
vi.spyOn(os, "release").mockReturnValue("10.0.19043")
const result = getSystemInfoSection(mockCwd)
expect(result).toContain("Operating System: win32 10.0.19043")
})
})

View file

@ -1,6 +1,53 @@
import type { SystemPromptSettings } from "../types"
import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types"
import { getShell } from "../../../utils/shell"
/**
* Returns the appropriate command chaining operator based on the user's shell.
* - Unix shells (bash, zsh, etc.): `&&` (run next command only if previous succeeds)
* - PowerShell: `;` (semicolon for command separation)
* - cmd.exe: `&&` (conditional execution, same as Unix)
* @internal Exported for testing purposes
*/
export function getCommandChainOperator(): string {
const shell = getShell().toLowerCase()
// Check for PowerShell (both Windows PowerShell and PowerShell Core)
if (shell.includes("powershell") || shell.includes("pwsh")) {
return ";"
}
// Check for cmd.exe
if (shell.includes("cmd.exe")) {
return "&&"
}
// Default to Unix-style && for bash, zsh, sh, and other shells
// This also covers Git Bash, WSL, and other Unix-like environments on Windows
return "&&"
}
/**
* Returns a shell-specific note about command chaining syntax and platform-specific utilities.
*/
function getCommandChainNote(): string {
const shell = getShell().toLowerCase()
// Check for PowerShell
if (shell.includes("powershell") || shell.includes("pwsh")) {
return "Note: Using `;` for PowerShell command chaining. For bash/zsh use `&&`, for cmd.exe use `&&`. IMPORTANT: When using PowerShell, avoid Unix-specific utilities like `sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`. Instead use PowerShell equivalents: `Select-String` for grep, `Get-Content` for cat, `Remove-Item` for rm, `Copy-Item` for cp, `Move-Item` for mv, and PowerShell's `-replace` operator or `[regex]` for sed."
}
// Check for cmd.exe
if (shell.includes("cmd.exe")) {
return "Note: Using `&&` for cmd.exe command chaining (conditional execution). For bash/zsh use `&&`, for PowerShell use `;`. IMPORTANT: When using cmd.exe, avoid Unix-specific utilities like `sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`. Use built-in commands like `type` for cat, `del` for rm, `copy` for cp, `move` for mv, `find`/`findstr` for grep, or consider using PowerShell commands instead."
}
// Unix shells
return ""
}
function getVendorConfidentialitySection(): string {
return `
@ -20,6 +67,10 @@ export function getRulesSection(cwd: string, settings?: SystemPromptSettings): s
// Determine whether to use XML tool references based on protocol
const effectiveProtocol = getEffectiveProtocol(settings?.toolProtocol)
// Get shell-appropriate command chaining operator
const chainOp = getCommandChainOperator()
const chainNote = getCommandChainNote()
return `====
RULES
@ -28,7 +79,7 @@ RULES
- All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to ${isNativeProtocol(effectiveProtocol) ? "execute_command" : "<execute_command>"}.
- You cannot \`cd\` into a different directory to complete a task. You are stuck operating from '${cwd.toPosix()}', so be sure to pass in the correct 'path' parameter when using tools that require a path.
- Do not use the ~ character or $HOME to refer to the home directory.
- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory && then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) && (command, in this case npm install)\`.
- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory ${chainOp} then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) ${chainOp} (command, in this case npm install)\`.${chainNote ? ` ${chainNote}` : ""}
- Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode.
- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.
* For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\\.md$"

View file

@ -4,11 +4,22 @@ import osName from "os-name"
import { getShell } from "../../../utils/shell"
export function getSystemInfoSection(cwd: string): string {
// Try to get detailed OS name, fall back to basic info if it fails
let osInfo: string
try {
osInfo = osName()
} catch (error) {
// Fallback when os-name fails (e.g., PowerShell not available on Windows)
const platform = os.platform()
const release = os.release()
osInfo = `${platform} ${release}`
}
let details = `====
SYSTEM INFORMATION
Operating System: ${osName()}
Operating System: ${osInfo}
Default Shell: ${getShell()}
Home Directory: ${os.homedir().toPosix()}
Current Workspace Directory: ${cwd.toPosix()}

View file

@ -21,6 +21,8 @@ export type TaskMetadataOptions = {
globalStoragePath: string
workspace: string
mode?: string
/** Provider profile name for the task (sticky profile feature) */
apiConfigName?: string
/** Initial status for the task (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
/**
@ -39,6 +41,7 @@ export async function taskMetadata({
globalStoragePath,
workspace,
mode,
apiConfigName,
initialStatus,
toolProtocol,
}: TaskMetadataOptions) {
@ -116,6 +119,7 @@ export async function taskMetadata({
workspace,
mode,
...(toolProtocol && { toolProtocol }),
...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}),
...(initialStatus && { status: initialStatus }),
}

View file

@ -247,6 +247,49 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
*/
private taskModeReady: Promise<void>
/**
* The API configuration name (provider profile) associated with this task.
* Persisted across sessions to maintain the provider profile when reopening tasks from history.
*
* ## Lifecycle
*
* ### For new tasks:
* 1. Initially `undefined` during construction
* 2. Asynchronously initialized from provider state via `initializeTaskApiConfigName()`
* 3. Falls back to "default" if provider state is unavailable
*
* ### For history items:
* 1. Immediately set from `historyItem.apiConfigName` during construction
* 2. Falls back to undefined if not stored in history (for backward compatibility)
*
* ## Important
* If you need a non-`undefined` provider profile (e.g., for profile-dependent operations),
* wait for `taskApiConfigReady` first (or use `getTaskApiConfigName()`).
* The sync `taskApiConfigName` getter may return `undefined` for backward compatibility.
*
* @private
* @see {@link getTaskApiConfigName} - For safe async access
* @see {@link taskApiConfigName} - For sync access after initialization
*/
private _taskApiConfigName: string | undefined
/**
* Promise that resolves when the task API config name has been initialized.
* This ensures async API config name initialization completes before the task is used.
*
* ## Purpose
* - Prevents race conditions when accessing task API config name
* - Ensures provider state is properly loaded before profile-dependent operations
* - Provides a synchronization point for async initialization
*
* ## Resolution timing
* - For history items: Resolves immediately (sync initialization)
* - For new tasks: Resolves after provider state is fetched (async initialization)
*
* @private
*/
private taskApiConfigReady: Promise<void>
providerRef: WeakRef<ClineProvider>
private readonly globalStoragePath: string
abort: boolean = false
@ -337,6 +380,28 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
presentAssistantMessageHasPendingUpdates = false
userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = []
userMessageContentReady = false
/**
* Push a tool_result block to userMessageContent, preventing duplicates.
* This is critical for native tool protocol where duplicate tool_use_ids cause API errors.
*
* @param toolResult - The tool_result block to add
* @returns true if added, false if duplicate was skipped
*/
public pushToolResultToUserContent(toolResult: Anthropic.ToolResultBlockParam): boolean {
const existingResult = this.userMessageContent.find(
(block): block is Anthropic.ToolResultBlockParam =>
block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
)
if (existingResult) {
console.warn(
`[Task#pushToolResultToUserContent] Skipping duplicate tool_result for tool_use_id: ${toolResult.tool_use_id}`,
)
return false
}
this.userMessageContent.push(toolResult)
return true
}
didRejectTool = false
didAlreadyUseTool = false
didToolFailInCurrentTurn = false
@ -480,21 +545,25 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.taskNumber = taskNumber
this.initialStatus = initialStatus
// Store the task's mode when it's created.
// For history items, use the stored mode; for new tasks, we'll set it
// Store the task's mode and API config name when it's created.
// For history items, use the stored values; for new tasks, we'll set them
// after getting state.
if (historyItem) {
this._taskMode = historyItem.mode || defaultModeSlug
this._taskApiConfigName = historyItem.apiConfigName
this.taskModeReady = Promise.resolve()
this.taskApiConfigReady = Promise.resolve()
TelemetryService.instance.captureTaskRestarted(this.taskId)
// For history items, use the persisted tool protocol if available.
// If not available (old tasks), it will be detected in resumeTaskFromHistory.
this._taskToolProtocol = historyItem.toolProtocol
} else {
// For new tasks, don't set the mode yet - wait for async initialization.
// For new tasks, don't set the mode/apiConfigName yet - wait for async initialization.
this._taskMode = undefined
this._taskApiConfigName = undefined
this.taskModeReady = this.initializeTaskMode(provider)
this.taskApiConfigReady = this.initializeTaskApiConfigName(provider)
TelemetryService.instance.captureTaskCreated(this.taskId)
// For new tasks, resolve and lock the tool protocol immediately.
@ -617,6 +686,47 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}
/**
* Initialize the task API config name from the provider state.
* This method handles async initialization with proper error handling.
*
* ## Flow
* 1. Attempts to fetch the current API config name from provider state
* 2. Sets `_taskApiConfigName` to the fetched name or "default" if unavailable
* 3. Handles errors gracefully by falling back to "default"
* 4. Logs any initialization errors for debugging
*
* ## Error handling
* - Network failures when fetching provider state
* - Provider not yet initialized
* - Invalid state structure
*
* All errors result in fallback to "default" to ensure task can proceed.
*
* @private
* @param provider - The ClineProvider instance to fetch state from
* @returns Promise that resolves when initialization is complete
*/
private async initializeTaskApiConfigName(provider: ClineProvider): Promise<void> {
try {
const state = await provider.getState()
// Avoid clobbering a newer value that may have been set while awaiting provider state
// (e.g., user switches provider profile immediately after task creation).
if (this._taskApiConfigName === undefined) {
this._taskApiConfigName = state?.currentApiConfigName ?? "default"
}
} catch (error) {
// If there's an error getting state, use the default profile (unless a newer value was set).
if (this._taskApiConfigName === undefined) {
this._taskApiConfigName = "default"
}
// Use the provider's log method for better error visibility
const errorMessage = `Failed to initialize task API config name: ${error instanceof Error ? error.message : String(error)}`
provider.log(errorMessage)
}
}
/**
* Sets up a listener for provider profile changes to automatically update the parser state.
* This ensures the XML/native protocol parser stays synchronized with the current model.
@ -737,6 +847,73 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
return this._taskMode
}
/**
* Wait for the task API config name to be initialized before proceeding.
* This method ensures that any operations depending on the task's provider profile
* will have access to the correct value.
*
* ## When to use
* - Before accessing provider profile-specific configurations
* - When switching between tasks with different provider profiles
* - Before operations that depend on the provider profile
*
* @returns Promise that resolves when the task API config name is initialized
* @public
*/
public async waitForApiConfigInitialization(): Promise<void> {
return this.taskApiConfigReady
}
/**
* Get the task API config name asynchronously, ensuring it's properly initialized.
* This is the recommended way to access the task's provider profile as it guarantees
* the value is available before returning.
*
* ## Async behavior
* - Internally waits for `taskApiConfigReady` promise to resolve
* - Returns the initialized API config name or undefined as fallback
* - Safe to call multiple times - subsequent calls return immediately if already initialized
*
* @returns Promise resolving to the task API config name string or undefined
* @public
*/
public async getTaskApiConfigName(): Promise<string | undefined> {
await this.taskApiConfigReady
return this._taskApiConfigName
}
/**
* Get the task API config name synchronously. This should only be used when you're certain
* that the value has already been initialized (e.g., after waitForApiConfigInitialization).
*
* ## When to use
* - In synchronous contexts where async/await is not available
* - After explicitly waiting for initialization via `waitForApiConfigInitialization()`
* - In event handlers or callbacks where API config name is guaranteed to be initialized
*
* Note: Unlike taskMode, this getter does not throw if uninitialized since the API config
* name can legitimately be undefined (backward compatibility with tasks created before
* this feature was added).
*
* @returns The task API config name string or undefined
* @public
*/
public get taskApiConfigName(): string | undefined {
return this._taskApiConfigName
}
/**
* Update the task's API config name. This is called when the user switches
* provider profiles while a task is active, allowing the task to remember
* its new provider profile.
*
* @param apiConfigName - The new API config name to set
* @internal
*/
public setTaskApiConfigName(apiConfigName: string | undefined): void {
this._taskApiConfigName = apiConfigName
}
static create(options: TaskOptions): [Task, Promise<void>] {
const instance = new Task({ ...options, startTask: false })
const { images, task, historyItem } = options
@ -1005,6 +1182,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
globalStoragePath: this.globalStoragePath,
})
if (this._taskApiConfigName === undefined) {
await this.taskApiConfigReady
}
const { historyItem, tokenUsage } = await taskMetadata({
taskId: this.taskId,
rootTaskId: this.rootTaskId,
@ -1014,6 +1195,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
globalStoragePath: this.globalStoragePath,
workspace: this.cwd,
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode.
apiConfigName: this._taskApiConfigName, // Use the task's own provider profile, not the current provider profile.
initialStatus: this.initialStatus,
toolProtocol: this._taskToolProtocol, // Persist the locked tool protocol.
})
@ -4092,7 +4274,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Respect provider rate limit window
let rateLimitDelay = 0
const rateLimit = state?.apiConfiguration?.rateLimitSeconds || 0
const rateLimit = (state?.apiConfiguration ?? this.apiConfiguration)?.rateLimitSeconds || 0
if (Task.lastGlobalApiRequestTime && rateLimit > 0) {
const elapsed = performance.now() - Task.lastGlobalApiRequestTime
rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - elapsed) / 1000))

View file

@ -1977,3 +1977,205 @@ describe("Queued message processing after condense", () => {
expect(taskB.messageQueueService.isEmpty()).toBe(true)
})
})
describe("pushToolResultToUserContent", () => {
let mockProvider: any
let mockApiConfig: ProviderSettings
beforeEach(() => {
mockApiConfig = {
apiProvider: "anthropic",
apiModelId: "claude-3-5-sonnet-20241022",
apiKey: "test-api-key",
}
const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") }
const mockExtensionContext = {
globalState: {
get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined),
update: vi.fn().mockResolvedValue(undefined),
keys: vi.fn().mockReturnValue([]),
},
globalStorageUri: storageUri,
workspaceState: {
get: vi.fn().mockImplementation((_key) => undefined),
update: vi.fn().mockResolvedValue(undefined),
keys: vi.fn().mockReturnValue([]),
},
secrets: {
get: vi.fn().mockResolvedValue(undefined),
store: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
},
extensionUri: { fsPath: "/mock/extension/path" },
extension: { packageJSON: { version: "1.0.0" } },
} as unknown as vscode.ExtensionContext
const mockOutputChannel = {
name: "test-output",
appendLine: vi.fn(),
append: vi.fn(),
replace: vi.fn(),
clear: vi.fn(),
show: vi.fn(),
hide: vi.fn(),
dispose: vi.fn(),
}
mockProvider = new ClineProvider(
mockExtensionContext,
mockOutputChannel,
"sidebar",
new ContextProxy(mockExtensionContext),
) as any
mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
})
it("should add tool_result when not a duplicate", () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
const toolResult: Anthropic.ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "test-id-1",
content: "Test result",
}
const added = task.pushToolResultToUserContent(toolResult)
expect(added).toBe(true)
expect(task.userMessageContent).toHaveLength(1)
expect(task.userMessageContent[0]).toEqual(toolResult)
})
it("should prevent duplicate tool_result with same tool_use_id", () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
const toolResult1: Anthropic.ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "duplicate-id",
content: "First result",
}
const toolResult2: Anthropic.ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "duplicate-id",
content: "Second result (should be skipped)",
}
// Spy on console.warn to verify warning is logged
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
// Add first result - should succeed
const added1 = task.pushToolResultToUserContent(toolResult1)
expect(added1).toBe(true)
expect(task.userMessageContent).toHaveLength(1)
// Add second result with same ID - should be skipped
const added2 = task.pushToolResultToUserContent(toolResult2)
expect(added2).toBe(false)
expect(task.userMessageContent).toHaveLength(1)
// Verify only the first result is in the array
expect(task.userMessageContent[0]).toEqual(toolResult1)
// Verify warning was logged
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Skipping duplicate tool_result for tool_use_id: duplicate-id"),
)
warnSpy.mockRestore()
})
it("should allow different tool_use_ids to be added", () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
const toolResult1: Anthropic.ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "id-1",
content: "Result 1",
}
const toolResult2: Anthropic.ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "id-2",
content: "Result 2",
}
const added1 = task.pushToolResultToUserContent(toolResult1)
const added2 = task.pushToolResultToUserContent(toolResult2)
expect(added1).toBe(true)
expect(added2).toBe(true)
expect(task.userMessageContent).toHaveLength(2)
expect(task.userMessageContent[0]).toEqual(toolResult1)
expect(task.userMessageContent[1]).toEqual(toolResult2)
})
it("should handle tool_result with is_error flag", () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
const errorResult: Anthropic.ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "error-id",
content: "Error message",
is_error: true,
}
const added = task.pushToolResultToUserContent(errorResult)
expect(added).toBe(true)
expect(task.userMessageContent).toHaveLength(1)
expect(task.userMessageContent[0]).toEqual(errorResult)
})
it("should not interfere with other content types in userMessageContent", () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
// Add text and image blocks manually
task.userMessageContent.push(
{ type: "text", text: "Some text" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } },
)
const toolResult: Anthropic.ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "test-id",
content: "Result",
}
const added = task.pushToolResultToUserContent(toolResult)
expect(added).toBe(true)
expect(task.userMessageContent).toHaveLength(3)
expect(task.userMessageContent[0].type).toBe("text")
expect(task.userMessageContent[1].type).toBe("image")
expect(task.userMessageContent[2]).toEqual(toolResult)
})
})

View file

@ -0,0 +1,142 @@
// npx vitest run core/task/__tests__/Task.sticky-profile-race.spec.ts
import * as vscode from "vscode"
import type { ProviderSettings } from "@roo-code/types"
import { Task } from "../Task"
import { ClineProvider } from "../../webview/ClineProvider"
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
hasInstance: vi.fn().mockReturnValue(true),
createInstance: vi.fn(),
get instance() {
return {
captureTaskCreated: vi.fn(),
captureTaskRestarted: vi.fn(),
captureModeSwitch: vi.fn(),
captureConversationMessage: vi.fn(),
captureLlmCompletion: vi.fn(),
captureConsecutiveMistakeError: vi.fn(),
captureCodeActionUsed: vi.fn(),
setProvider: vi.fn(),
}
},
},
}))
vi.mock("vscode", () => {
const mockDisposable = { dispose: vi.fn() }
const mockEventEmitter = { event: vi.fn(), fire: vi.fn() }
const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } }
const mockTextEditor = { document: mockTextDocument }
const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } }
const mockTabGroup = { tabs: [mockTab] }
return {
TabInputTextDiff: vi.fn(),
CodeActionKind: {
QuickFix: { value: "quickfix" },
RefactorRewrite: { value: "refactor.rewrite" },
},
window: {
createTextEditorDecorationType: vi.fn().mockReturnValue({
dispose: vi.fn(),
}),
visibleTextEditors: [mockTextEditor],
tabGroups: {
all: [mockTabGroup],
close: vi.fn(),
onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })),
},
showErrorMessage: vi.fn(),
},
workspace: {
getConfiguration: vi.fn(() => ({ get: (_k: string, d: any) => d })),
workspaceFolders: [
{
uri: { fsPath: "/mock/workspace/path" },
name: "mock-workspace",
index: 0,
},
],
createFileSystemWatcher: vi.fn(() => ({
onDidCreate: vi.fn(() => mockDisposable),
onDidDelete: vi.fn(() => mockDisposable),
onDidChange: vi.fn(() => mockDisposable),
dispose: vi.fn(),
})),
fs: {
stat: vi.fn().mockResolvedValue({ type: 1 }),
},
onDidSaveTextDocument: vi.fn(() => mockDisposable),
},
env: {
uriScheme: "vscode",
language: "en",
},
EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter),
Disposable: {
from: vi.fn(),
},
TabInputText: vi.fn(),
version: "1.85.0",
}
})
vi.mock("../../environment/getEnvironmentDetails", () => ({
getEnvironmentDetails: vi.fn().mockResolvedValue(""),
}))
vi.mock("../../ignore/RooIgnoreController")
vi.mock("p-wait-for", () => ({
default: vi.fn().mockImplementation(async () => Promise.resolve()),
}))
vi.mock("delay", () => ({
__esModule: true,
default: vi.fn().mockResolvedValue(undefined),
}))
describe("Task - sticky provider profile init race", () => {
it("does not overwrite task apiConfigName if set during async initialization", async () => {
const apiConfig: ProviderSettings = {
apiProvider: "anthropic",
apiModelId: "claude-3-5-sonnet-20241022",
apiKey: "test-api-key",
} as any
let resolveGetState: ((v: any) => void) | undefined
const getStatePromise = new Promise((resolve) => {
resolveGetState = resolve
})
const mockProvider = {
context: {
globalStorageUri: { fsPath: "/test/storage" },
},
getState: vi.fn().mockImplementation(() => getStatePromise),
log: vi.fn(),
on: vi.fn(),
off: vi.fn(),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
} as unknown as ClineProvider
const task = new Task({
provider: mockProvider,
apiConfiguration: apiConfig,
task: "test task",
startTask: false,
})
// Simulate a profile switch happening before provider.getState resolves.
task.setTaskApiConfigName("new-profile")
resolveGetState?.({ currentApiConfigName: "old-profile" })
await task.waitForApiConfigInitialization()
expect(task.taskApiConfigName).toBe("new-profile")
})
})

View file

@ -157,7 +157,7 @@ export class ClineProvider
public isViewLaunched = false
public settingsImportedAt?: number
public readonly latestAnnouncementId = "dec-2025-v3.38.0-skills-native-tool-calling" // v3.38.0 Skills & Native Tool Calling Required
public readonly latestAnnouncementId = "jan-2026-v3.39.0-sticky-profiles-image-mentions-brrr" // v3.39.0 Sticky Profiles, Image @Mentions, BRRR Mode
public readonly providerSettingsManager: ProviderSettingsManager
public readonly customModesManager: CustomModesManager
@ -892,42 +892,78 @@ export class ClineProvider
await this.updateGlobalState("mode", historyItem.mode)
// Load the saved API config for the restored mode if it exists.
const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
const listApiConfig = await this.providerSettingsManager.listConfig()
// Skip mode-based profile activation if historyItem.apiConfigName exists,
// since the task's specific provider profile will override it anyway.
if (!historyItem.apiConfigName) {
const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
const listApiConfig = await this.providerSettingsManager.listConfig()
// Update listApiConfigMeta first to ensure UI has latest data.
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
// Update listApiConfigMeta first to ensure UI has latest data.
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
// If this mode has a saved config, use it.
if (savedConfigId) {
const profile = listApiConfig.find(({ id }) => id === savedConfigId)
// If this mode has a saved config, use it.
if (savedConfigId) {
const profile = listApiConfig.find(({ id }) => id === savedConfigId)
if (profile?.name) {
try {
// Check if the profile has actual API configuration (not just an id).
// In CLI mode, the ProviderSettingsManager may return empty default profiles
// that only contain 'id' and 'name' fields. Activating such a profile would
// overwrite the CLI's working API configuration with empty settings.
const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name })
const hasActualSettings = !!fullProfile.apiProvider
if (profile?.name) {
try {
// Check if the profile has actual API configuration (not just an id).
// In CLI mode, the ProviderSettingsManager may return empty default profiles
// that only contain 'id' and 'name' fields. Activating such a profile would
// overwrite the CLI's working API configuration with empty settings.
const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name })
const hasActualSettings = !!fullProfile.apiProvider
if (hasActualSettings) {
await this.activateProviderProfile({ name: profile.name })
} else {
if (hasActualSettings) {
await this.activateProviderProfile({ name: profile.name })
} else {
// The task will continue with the current/default configuration.
}
} catch (error) {
// Log the error but continue with task restoration.
this.log(
`Failed to restore API configuration for mode '${historyItem.mode}': ${
error instanceof Error ? error.message : String(error)
}. Continuing with default configuration.`,
)
// The task will continue with the current/default configuration.
}
} catch (error) {
// Log the error but continue with task restoration.
this.log(
`Failed to restore API configuration for mode '${historyItem.mode}': ${
error instanceof Error ? error.message : String(error)
}. Continuing with default configuration.`,
)
}
}
}
}
// If the history item has a saved API config name (provider profile), restore it.
// This overrides any mode-based config restoration above, because the task's
// specific provider profile takes precedence over mode defaults.
if (historyItem.apiConfigName) {
const listApiConfig = await this.providerSettingsManager.listConfig()
// Keep global state/UI in sync with latest profiles for parity with mode restoration above.
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
const profile = listApiConfig.find(({ name }) => name === historyItem.apiConfigName)
if (profile?.name) {
try {
await this.activateProviderProfile(
{ name: profile.name },
{ persistModeConfig: false, persistTaskHistory: false },
)
} catch (error) {
// Log the error but continue with task restoration.
this.log(
`Failed to restore API configuration '${historyItem.apiConfigName}' for task: ${
error instanceof Error ? error.message : String(error)
}. Continuing with current configuration.`,
)
}
} else {
// Profile no longer exists, log warning but continue
this.log(
`Provider profile '${historyItem.apiConfigName}' from history no longer exists. Using current configuration.`,
)
}
}
const {
apiConfiguration,
diffEnabled: enableDiff,
@ -1426,6 +1462,9 @@ export class ClineProvider
// Change the provider for the current task.
// TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`).
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
// Keep the current task's sticky provider profile in sync with the newly-activated profile.
await this.persistStickyProviderProfileToCurrentTask(name)
} else {
await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
}
@ -1465,9 +1504,42 @@ export class ClineProvider
await this.postStateToWebview()
}
async activateProviderProfile(args: { name: string } | { id: string }) {
private async persistStickyProviderProfileToCurrentTask(apiConfigName: string): Promise<void> {
const task = this.getCurrentTask()
if (!task) {
return
}
try {
// Update in-memory state immediately so sticky behavior works even before the task has
// been persisted into taskHistory (it will be captured on the next save).
task.setTaskApiConfigName(apiConfigName)
const history = this.getGlobalState("taskHistory") ?? []
const taskHistoryItem = history.find((item) => item.id === task.taskId)
if (taskHistoryItem) {
await this.updateTaskHistory({ ...taskHistoryItem, apiConfigName })
}
} catch (error) {
// If persistence fails, log the error but don't fail the profile switch.
this.log(
`Failed to persist provider profile switch for task ${task.taskId}: ${
error instanceof Error ? error.message : String(error)
}`,
)
}
}
async activateProviderProfile(
args: { name: string } | { id: string },
options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean },
) {
const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args)
const persistModeConfig = options?.persistModeConfig ?? true
const persistTaskHistory = options?.persistTaskHistory ?? true
// See `upsertProviderProfile` for a description of what this is doing.
await Promise.all([
this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
@ -1477,13 +1549,19 @@ export class ClineProvider
const { mode } = await this.getState()
if (id) {
if (id && persistModeConfig) {
await this.providerSettingsManager.setModeConfig(mode, id)
}
// Change the provider for the current task.
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
// Update the current task's sticky provider profile, unless this activation is
// being used purely as a non-persisting restoration (e.g., reopening a task from history).
if (persistTaskHistory) {
await this.persistStickyProviderProfileToCurrentTask(name)
}
await this.postStateToWebview()
if (providerSettings.apiProvider) {

View file

@ -3054,7 +3054,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0]])
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }])
// Verify submitUserMessage was called with the edited content
expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with preserved images", undefined)
expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with preserved images", [])
})
test("handles editing messages with file attachments", async () => {
@ -3107,7 +3107,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
})
expect(mockCline.overwriteClineMessages).toHaveBeenCalled()
expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with file attachment", undefined)
expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with file attachment", [])
})
})
@ -3638,7 +3638,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: largeEditedContent })
expect(mockCline.overwriteClineMessages).toHaveBeenCalled()
expect(mockCline.submitUserMessage).toHaveBeenCalledWith(largeEditedContent, undefined)
expect(mockCline.submitUserMessage).toHaveBeenCalledWith(largeEditedContent, [])
})
test("handles deleting messages with large payloads", async () => {

View file

@ -0,0 +1,883 @@
// npx vitest run core/webview/__tests__/ClineProvider.sticky-profile.spec.ts
import * as vscode from "vscode"
import { TelemetryService } from "@roo-code/telemetry"
import { ClineProvider } from "../ClineProvider"
import { ContextProxy } from "../../config/ContextProxy"
import type { HistoryItem } from "@roo-code/types"
vi.mock("vscode", () => ({
ExtensionContext: vi.fn(),
OutputChannel: vi.fn(),
WebviewView: vi.fn(),
Uri: {
joinPath: vi.fn(),
file: vi.fn(),
},
CodeActionKind: {
QuickFix: { value: "quickfix" },
RefactorRewrite: { value: "refactor.rewrite" },
},
commands: {
executeCommand: vi.fn().mockResolvedValue(undefined),
},
window: {
showInformationMessage: vi.fn(),
showWarningMessage: vi.fn(),
showErrorMessage: vi.fn(),
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
},
workspace: {
getConfiguration: vi.fn().mockReturnValue({
get: vi.fn().mockReturnValue([]),
update: vi.fn(),
}),
onDidChangeConfiguration: vi.fn().mockImplementation(() => ({
dispose: vi.fn(),
})),
onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
},
env: {
uriScheme: "vscode",
language: "en",
appName: "Visual Studio Code",
},
ExtensionMode: {
Production: 1,
Development: 2,
Test: 3,
},
version: "1.85.0",
}))
// Create a counter for unique task IDs.
let taskIdCounter = 0
vi.mock("../../task/Task", () => ({
Task: vi.fn().mockImplementation((options) => ({
taskId: options.taskId || `test-task-id-${++taskIdCounter}`,
saveClineMessages: vi.fn(),
clineMessages: [],
apiConversationHistory: [],
overwriteClineMessages: vi.fn(),
overwriteApiConversationHistory: vi.fn(),
abortTask: vi.fn(),
handleWebviewAskResponse: vi.fn(),
getTaskNumber: vi.fn().mockReturnValue(0),
setTaskNumber: vi.fn(),
setParentTask: vi.fn(),
setRootTask: vi.fn(),
emit: vi.fn(),
parentTask: options.parentTask,
updateApiConfiguration: vi.fn(),
setTaskApiConfigName: vi.fn(),
_taskApiConfigName: options.historyItem?.apiConfigName,
taskApiConfigName: options.historyItem?.apiConfigName,
})),
}))
vi.mock("../../prompts/sections/custom-instructions")
vi.mock("../../../utils/safeWriteJson")
vi.mock("../../../api", () => ({
buildApiHandler: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue({
id: "claude-3-sonnet",
}),
}),
}))
vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({
default: vi.fn().mockImplementation(() => ({
initializeFilePaths: vi.fn(),
dispose: vi.fn(),
})),
}))
vi.mock("../../diff/strategies/multi-search-replace", () => ({
MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(() => ({
getToolDescription: () => "test",
getName: () => "test-strategy",
applyDiff: vi.fn(),
})),
}))
vi.mock("@roo-code/cloud", () => ({
CloudService: {
hasInstance: vi.fn().mockReturnValue(true),
get instance() {
return {
isAuthenticated: vi.fn().mockReturnValue(false),
}
},
},
BridgeOrchestrator: {
isEnabled: vi.fn().mockReturnValue(false),
},
getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"),
}))
vi.mock("../../../shared/modes", () => ({
modes: [
{
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
},
{
slug: "architect",
name: "Architect Mode",
roleDefinition: "You are an architect",
groups: ["read", "edit"],
},
],
getModeBySlug: vi.fn().mockReturnValue({
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
}),
defaultModeSlug: "code",
}))
vi.mock("../../prompts/system", () => ({
SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"),
codeMode: "code",
}))
vi.mock("../../../api/providers/fetchers/modelCache", () => ({
getModels: vi.fn().mockResolvedValue({}),
flushModels: vi.fn(),
}))
vi.mock("../../../integrations/misc/extract-text", () => ({
extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"),
}))
vi.mock("p-wait-for", () => ({
default: vi.fn().mockImplementation(async () => Promise.resolve()),
}))
vi.mock("fs/promises", () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(""),
unlink: vi.fn().mockResolvedValue(undefined),
rmdir: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
hasInstance: vi.fn().mockReturnValue(true),
createInstance: vi.fn(),
get instance() {
return {
trackEvent: vi.fn(),
trackError: vi.fn(),
setProvider: vi.fn(),
captureModeSwitch: vi.fn(),
}
},
},
}))
describe("ClineProvider - Sticky Provider Profile", () => {
let provider: ClineProvider
let mockContext: vscode.ExtensionContext
let mockOutputChannel: vscode.OutputChannel
let mockWebviewView: vscode.WebviewView
let mockPostMessage: any
beforeEach(() => {
vi.clearAllMocks()
taskIdCounter = 0
if (!TelemetryService.hasInstance()) {
TelemetryService.createInstance([])
}
const globalState: Record<string, string | undefined> = {
mode: "code",
currentApiConfigName: "default-profile",
}
const secrets: Record<string, string | undefined> = {}
mockContext = {
extensionPath: "/test/path",
extensionUri: {} as vscode.Uri,
globalState: {
get: vi.fn().mockImplementation((key: string) => globalState[key]),
update: vi.fn().mockImplementation((key: string, value: string | undefined) => {
globalState[key] = value
return Promise.resolve()
}),
keys: vi.fn().mockImplementation(() => Object.keys(globalState)),
},
secrets: {
get: vi.fn().mockImplementation((key: string) => secrets[key]),
store: vi.fn().mockImplementation((key: string, value: string | undefined) => {
secrets[key] = value
return Promise.resolve()
}),
delete: vi.fn().mockImplementation((key: string) => {
delete secrets[key]
return Promise.resolve()
}),
},
subscriptions: [],
extension: {
packageJSON: { version: "1.0.0" },
},
globalStorageUri: {
fsPath: "/test/storage/path",
},
} as unknown as vscode.ExtensionContext
mockOutputChannel = {
appendLine: vi.fn(),
clear: vi.fn(),
dispose: vi.fn(),
} as unknown as vscode.OutputChannel
mockPostMessage = vi.fn()
mockWebviewView = {
webview: {
postMessage: mockPostMessage,
html: "",
options: {},
onDidReceiveMessage: vi.fn(),
asWebviewUri: vi.fn(),
cspSource: "vscode-webview://test-csp-source",
},
visible: true,
onDidDispose: vi.fn().mockImplementation((callback) => {
callback()
return { dispose: vi.fn() }
}),
onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })),
} as unknown as vscode.WebviewView
provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
// Mock getMcpHub method
provider.getMcpHub = vi.fn().mockReturnValue({
listTools: vi.fn().mockResolvedValue([]),
callTool: vi.fn().mockResolvedValue({ content: [] }),
listResources: vi.fn().mockResolvedValue([]),
readResource: vi.fn().mockResolvedValue({ contents: [] }),
getAllServers: vi.fn().mockReturnValue([]),
})
})
describe("activateProviderProfile", () => {
beforeEach(async () => {
await provider.resolveWebviewView(mockWebviewView)
})
it("should save provider profile to task metadata when switching profiles", async () => {
// Create a mock task
const mockTask = {
taskId: "test-task-id",
_taskApiConfigName: "default-profile",
setTaskApiConfigName: vi.fn(),
emit: vi.fn(),
saveClineMessages: vi.fn(),
clineMessages: [],
apiConversationHistory: [],
updateApiConfiguration: vi.fn(),
}
// Add task to provider stack
await provider.addClineToStack(mockTask as any)
// Mock getGlobalState to return task history
vi.spyOn(provider as any, "getGlobalState").mockReturnValue([
{
id: mockTask.taskId,
ts: Date.now(),
task: "Test task",
number: 1,
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
},
])
// Mock updateTaskHistory to track calls
const updateTaskHistorySpy = vi
.spyOn(provider, "updateTaskHistory")
.mockImplementation(() => Promise.resolve([]))
// Mock providerSettingsManager.activateProfile
vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
name: "new-profile",
id: "new-profile-id",
apiProvider: "anthropic",
})
// Mock providerSettingsManager.listConfig
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" },
])
// Switch provider profile
await provider.activateProviderProfile({ name: "new-profile" })
// Verify task history was updated with new provider profile
expect(updateTaskHistorySpy).toHaveBeenCalledWith(
expect.objectContaining({
id: mockTask.taskId,
apiConfigName: "new-profile",
}),
)
// Verify task's setTaskApiConfigName was called
expect(mockTask.setTaskApiConfigName).toHaveBeenCalledWith("new-profile")
})
it("should update task's taskApiConfigName property when switching profiles", async () => {
// Create a mock task with initial profile
const mockTask = {
taskId: "test-task-id",
_taskApiConfigName: "default-profile",
setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) {
this._taskApiConfigName = name
}),
emit: vi.fn(),
saveClineMessages: vi.fn(),
clineMessages: [],
apiConversationHistory: [],
updateApiConfiguration: vi.fn(),
}
// Add task to provider stack
await provider.addClineToStack(mockTask as any)
// Mock getGlobalState to return task history
vi.spyOn(provider as any, "getGlobalState").mockReturnValue([
{
id: mockTask.taskId,
ts: Date.now(),
task: "Test task",
number: 1,
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
},
])
// Mock updateTaskHistory
vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => Promise.resolve([]))
// Mock providerSettingsManager.activateProfile
vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
name: "new-profile",
id: "new-profile-id",
apiProvider: "openrouter",
})
// Mock providerSettingsManager.listConfig
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "new-profile", id: "new-profile-id", apiProvider: "openrouter" },
])
// Switch provider profile
await provider.activateProviderProfile({ name: "new-profile" })
// Verify task's _taskApiConfigName property was updated
expect(mockTask._taskApiConfigName).toBe("new-profile")
})
it("should update in-memory task profile even if task history item does not exist yet", async () => {
await provider.resolveWebviewView(mockWebviewView)
const mockTask = {
taskId: "test-task-id",
_taskApiConfigName: "default-profile",
setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) {
this._taskApiConfigName = name
}),
emit: vi.fn(),
saveClineMessages: vi.fn(),
clineMessages: [],
apiConversationHistory: [],
updateApiConfiguration: vi.fn(),
}
await provider.addClineToStack(mockTask as any)
// No history item exists yet
vi.spyOn(provider as any, "getGlobalState").mockReturnValue([])
const updateTaskHistorySpy = vi
.spyOn(provider, "updateTaskHistory")
.mockImplementation(() => Promise.resolve([]))
vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
name: "new-profile",
id: "new-profile-id",
apiProvider: "openrouter",
})
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "new-profile", id: "new-profile-id", apiProvider: "openrouter" },
])
await provider.activateProviderProfile({ name: "new-profile" })
// In-memory should still update, even without a history item.
expect(mockTask._taskApiConfigName).toBe("new-profile")
// No history item => no updateTaskHistory call.
expect(updateTaskHistorySpy).not.toHaveBeenCalled()
})
})
describe("createTaskWithHistoryItem", () => {
it("should restore provider profile from history item when reopening task", async () => {
await provider.resolveWebviewView(mockWebviewView)
// Create a history item with saved provider profile
const historyItem: HistoryItem = {
id: "test-task-id",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 200,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0.001,
mode: "code",
apiConfigName: "saved-profile", // Saved provider profile
}
// Mock activateProviderProfile to track calls
const activateProviderProfileSpy = vi
.spyOn(provider, "activateProviderProfile")
.mockResolvedValue(undefined)
// Mock providerSettingsManager.listConfig
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "saved-profile", id: "saved-profile-id", apiProvider: "anthropic" },
])
// Initialize task with history item
await provider.createTaskWithHistoryItem(historyItem)
// Verify provider profile was restored via activateProviderProfile (restore-only: don't persist mode config)
expect(activateProviderProfileSpy).toHaveBeenCalledWith(
{ name: "saved-profile" },
{ persistModeConfig: false, persistTaskHistory: false },
)
})
it("should use current profile if history item has no saved apiConfigName", async () => {
await provider.resolveWebviewView(mockWebviewView)
// Create a history item without saved provider profile
const historyItem: HistoryItem = {
id: "test-task-id",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 200,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0.001,
// No apiConfigName field
}
// Mock activateProviderProfile to track calls
const activateProviderProfileSpy = vi
.spyOn(provider, "activateProviderProfile")
.mockResolvedValue(undefined)
// Initialize task with history item
await provider.createTaskWithHistoryItem(historyItem)
// Verify activateProviderProfile was NOT called for apiConfigName restoration
// (it might be called for mode-based config, but not for direct apiConfigName)
const callsForApiConfigName = activateProviderProfileSpy.mock.calls.filter(
(call) => call[0] && "name" in call[0] && call[0].name === historyItem.apiConfigName,
)
expect(callsForApiConfigName.length).toBe(0)
})
it("should override mode-based config with task's apiConfigName", async () => {
await provider.resolveWebviewView(mockWebviewView)
// Create a history item with both mode and apiConfigName
const historyItem: HistoryItem = {
id: "test-task-id",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 200,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0.001,
mode: "architect", // Mode has a different preferred profile
apiConfigName: "task-specific-profile", // Task's actual profile
}
// Track all activateProviderProfile calls
const activateCalls: string[] = []
vi.spyOn(provider, "activateProviderProfile").mockImplementation(async (args) => {
if ("name" in args) {
activateCalls.push(args.name)
}
})
// 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" },
])
// Initialize task with history item
await provider.createTaskWithHistoryItem(historyItem)
// Verify task's apiConfigName was activated LAST (overriding mode-based config)
expect(activateCalls[activateCalls.length - 1]).toBe("task-specific-profile")
})
it("should handle missing provider profile gracefully", async () => {
await provider.resolveWebviewView(mockWebviewView)
// Create a history item with a provider profile that no longer exists
const historyItem: HistoryItem = {
id: "test-task-id",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 200,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0.001,
apiConfigName: "deleted-profile", // Profile that doesn't exist
}
// Mock providerSettingsManager.listConfig to return empty (profile doesn't exist)
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([])
// Mock log to verify warning is logged
const logSpy = vi.spyOn(provider, "log")
// Initialize task with history item - should not throw
await expect(provider.createTaskWithHistoryItem(historyItem)).resolves.not.toThrow()
// Verify a warning was logged
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("Provider profile 'deleted-profile' from history no longer exists"),
)
})
})
describe("Task metadata persistence", () => {
it("should include apiConfigName in task metadata when saving", async () => {
await provider.resolveWebviewView(mockWebviewView)
// Create a mock task with provider profile
const mockTask = {
taskId: "test-task-id",
_taskApiConfigName: "test-profile",
setTaskApiConfigName: vi.fn(),
emit: vi.fn(),
saveClineMessages: vi.fn(),
clineMessages: [],
apiConversationHistory: [],
updateApiConfiguration: vi.fn(),
}
// Mock getGlobalState to return task history with our task
vi.spyOn(provider as any, "getGlobalState").mockReturnValue([
{
id: mockTask.taskId,
ts: Date.now(),
task: "Test task",
number: 1,
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
},
])
// Mock updateTaskHistory to capture the updated history item
let updatedHistoryItem: any
vi.spyOn(provider, "updateTaskHistory").mockImplementation((item) => {
updatedHistoryItem = item
return Promise.resolve([item])
})
// Add task to provider stack
await provider.addClineToStack(mockTask as any)
// Mock providerSettingsManager.activateProfile
vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
name: "new-profile",
id: "new-profile-id",
apiProvider: "anthropic",
})
// Mock providerSettingsManager.listConfig
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" },
])
// Trigger a profile switch
await provider.activateProviderProfile({ name: "new-profile" })
// Verify apiConfigName was included in the updated history item
expect(updatedHistoryItem).toBeDefined()
expect(updatedHistoryItem.apiConfigName).toBe("new-profile")
})
})
describe("Multiple workspaces isolation", () => {
it("should preserve task profile when switching profiles in another workspace", async () => {
// This test verifies that each task retains its designated provider profile
// so that switching profiles in one workspace doesn't alter other tasks
await provider.resolveWebviewView(mockWebviewView)
// Create task 1 with profile A
const task1 = {
taskId: "task-1",
_taskApiConfigName: "profile-a",
setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) {
this._taskApiConfigName = name
}),
emit: vi.fn(),
saveClineMessages: vi.fn(),
clineMessages: [],
apiConversationHistory: [],
updateApiConfiguration: vi.fn(),
}
// Create task 2 with profile B
const task2 = {
taskId: "task-2",
_taskApiConfigName: "profile-b",
setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) {
this._taskApiConfigName = name
}),
emit: vi.fn(),
saveClineMessages: vi.fn(),
clineMessages: [],
apiConversationHistory: [],
updateApiConfiguration: vi.fn(),
}
// Add task 1 to stack
await provider.addClineToStack(task1 as any)
// Mock getGlobalState to return task history for both tasks
const taskHistory = [
{
id: "task-1",
ts: Date.now(),
task: "Task 1",
number: 1,
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
apiConfigName: "profile-a",
},
{
id: "task-2",
ts: Date.now(),
task: "Task 2",
number: 2,
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
apiConfigName: "profile-b",
},
]
vi.spyOn(provider as any, "getGlobalState").mockReturnValue(taskHistory)
// Mock updateTaskHistory
vi.spyOn(provider, "updateTaskHistory").mockImplementation((item) => {
const index = taskHistory.findIndex((h) => h.id === item.id)
if (index >= 0) {
taskHistory[index] = { ...taskHistory[index], ...item }
}
return Promise.resolve(taskHistory)
})
// Mock providerSettingsManager.activateProfile
vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
name: "profile-c",
id: "profile-c-id",
apiProvider: "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" },
])
// Switch task 1's profile to profile C
await provider.activateProviderProfile({ name: "profile-c" })
// Verify task 1's profile was updated
expect(task1._taskApiConfigName).toBe("profile-c")
expect(taskHistory[0].apiConfigName).toBe("profile-c")
// Verify task 2's profile remains unchanged
expect(taskHistory[1].apiConfigName).toBe("profile-b")
})
})
describe("Error handling", () => {
it("should handle errors gracefully when saving profile fails", async () => {
await provider.resolveWebviewView(mockWebviewView)
// Create a mock task
const mockTask = {
taskId: "test-task-id",
_taskApiConfigName: "default-profile",
setTaskApiConfigName: vi.fn(),
emit: vi.fn(),
saveClineMessages: vi.fn(),
clineMessages: [],
apiConversationHistory: [],
updateApiConfiguration: vi.fn(),
}
// Add task to provider stack
await provider.addClineToStack(mockTask as any)
// Mock getGlobalState
vi.spyOn(provider as any, "getGlobalState").mockReturnValue([
{
id: mockTask.taskId,
ts: Date.now(),
task: "Test task",
number: 1,
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
},
])
// Mock updateTaskHistory to throw error
vi.spyOn(provider, "updateTaskHistory").mockRejectedValue(new Error("Save failed"))
// Mock providerSettingsManager.activateProfile
vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
name: "new-profile",
id: "new-profile-id",
apiProvider: "anthropic",
})
// Mock providerSettingsManager.listConfig
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" },
])
// Mock log to verify error is logged
const logSpy = vi.spyOn(provider, "log")
// Switch provider profile - should not throw
await expect(provider.activateProviderProfile({ name: "new-profile" })).resolves.not.toThrow()
// Verify error was logged
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to persist provider profile switch"))
})
it("should handle null/undefined apiConfigName gracefully", async () => {
await provider.resolveWebviewView(mockWebviewView)
// Create a history item with null apiConfigName
const historyItem: HistoryItem = {
id: "test-task-id",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 200,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0.001,
apiConfigName: null as any, // Invalid apiConfigName
}
// Mock activateProviderProfile to track calls
const activateProviderProfileSpy = vi
.spyOn(provider, "activateProviderProfile")
.mockResolvedValue(undefined)
// Initialize task with history item - should not throw
await expect(provider.createTaskWithHistoryItem(historyItem)).resolves.not.toThrow()
// Verify activateProviderProfile was not called with null
expect(activateProviderProfileSpy).not.toHaveBeenCalledWith({ name: null })
})
})
describe("Profile restoration with activateProfile failure", () => {
it("should continue task restoration even if activateProviderProfile fails", async () => {
await provider.resolveWebviewView(mockWebviewView)
// Create a history item with saved provider profile
const historyItem: HistoryItem = {
id: "test-task-id",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 200,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0.001,
apiConfigName: "failing-profile",
}
// Mock providerSettingsManager.listConfig to return the profile
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "failing-profile", id: "failing-profile-id", apiProvider: "anthropic" },
])
// Mock activateProviderProfile to throw error
vi.spyOn(provider, "activateProviderProfile").mockRejectedValue(new Error("Activation failed"))
// Mock log to verify error is logged
const logSpy = vi.spyOn(provider, "log")
// Initialize task with history item - should not throw even though activation fails
await expect(provider.createTaskWithHistoryItem(historyItem)).resolves.not.toThrow()
// Verify error was logged
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to restore API configuration 'failing-profile' for task"),
)
})
})
})

View file

@ -55,6 +55,10 @@ describe("webviewMessageHandler - checkpoint operations", () => {
contextProxy: {
globalStorageUri: { fsPath: "/test/storage" },
},
getState: vi.fn().mockResolvedValue({
maxImageFileSize: 5,
maxTotalImageSize: 20,
}),
}
})
@ -124,7 +128,7 @@ describe("webviewMessageHandler - checkpoint operations", () => {
operation: "edit",
editData: {
editedContent: "Edited checkpoint message",
images: undefined,
images: [],
apiConversationHistoryIndex: 0,
},
})

View file

@ -71,6 +71,10 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => {
globalStorageUri: { fsPath: "/mock/storage" },
},
log: vi.fn(),
getState: vi.fn().mockResolvedValue({
maxImageFileSize: 5,
maxTotalImageSize: 20,
}),
} as unknown as ClineProvider
})

View file

@ -0,0 +1,130 @@
import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"
// Must mock dependencies before importing the handler module.
vi.mock("../../../api/providers/fetchers/modelCache")
import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
showErrorMessage: vi.fn(),
},
workspace: {
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
},
}))
// Mock imageHelpers - use actual implementations for functions that need real file access
vi.mock("../../tools/helpers/imageHelpers", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../tools/helpers/imageHelpers")>()
return {
...actual,
validateImageForProcessing: vi.fn().mockResolvedValue({ isValid: true, sizeInMB: 0.001 }),
ImageMemoryTracker: vi.fn().mockImplementation(() => ({
getTotalMemoryUsed: vi.fn().mockReturnValue(0),
addMemoryUsage: vi.fn(),
})),
}
})
describe("webviewMessageHandler - image mentions (integration)", () => {
it("resolves image mentions for newTask and passes images to createTask", async () => {
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-"))
try {
const imgBytes = Buffer.from("png-bytes")
await fs.writeFile(path.join(tmpRoot, "cat.png"), imgBytes)
const mockProvider = {
cwd: tmpRoot,
getCurrentTask: vi.fn().mockReturnValue(undefined),
createTask: vi.fn().mockResolvedValue(undefined),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
getState: vi.fn().mockResolvedValue({
maxImageFileSize: 5,
maxTotalImageSize: 20,
}),
} as unknown as ClineProvider
await webviewMessageHandler(mockProvider, {
type: "newTask",
text: "Please look at @/cat.png",
images: [],
} as any)
expect(mockProvider.createTask).toHaveBeenCalledWith("Please look at @/cat.png", [
`data:image/png;base64,${imgBytes.toString("base64")}`,
])
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true })
}
})
it("resolves image mentions for askResponse and passes images to handleWebviewAskResponse", async () => {
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-"))
try {
const imgBytes = Buffer.from("jpg-bytes")
await fs.writeFile(path.join(tmpRoot, "cat.jpg"), imgBytes)
const handleWebviewAskResponse = vi.fn()
const mockProvider = {
cwd: tmpRoot,
getCurrentTask: vi.fn().mockReturnValue({
cwd: tmpRoot,
handleWebviewAskResponse,
}),
getState: vi.fn().mockResolvedValue({
maxImageFileSize: 5,
maxTotalImageSize: 20,
}),
} as unknown as ClineProvider
await webviewMessageHandler(mockProvider, {
type: "askResponse",
askResponse: "messageResponse",
text: "Please look at @/cat.jpg",
images: [],
} as any)
expect(handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Please look at @/cat.jpg", [
`data:image/jpeg;base64,${imgBytes.toString("base64")}`,
])
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true })
}
})
it("resolves gif image mentions (matching read_file behavior)", async () => {
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-"))
try {
const imgBytes = Buffer.from("gif-bytes")
await fs.writeFile(path.join(tmpRoot, "animation.gif"), imgBytes)
const mockProvider = {
cwd: tmpRoot,
getCurrentTask: vi.fn().mockReturnValue(undefined),
createTask: vi.fn().mockResolvedValue(undefined),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
getState: vi.fn().mockResolvedValue({
maxImageFileSize: 5,
maxTotalImageSize: 20,
}),
} as unknown as ClineProvider
await webviewMessageHandler(mockProvider, {
type: "newTask",
text: "See @/animation.gif",
images: [],
} as any)
expect(mockProvider.createTask).toHaveBeenCalledWith("See @/animation.gif", [
`data:image/gif;base64,${imgBytes.toString("base64")}`,
])
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true })
}
})
})

View file

@ -0,0 +1,297 @@
// npx vitest core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts
import type { Mock } from "vitest"
// Mock dependencies - must come before imports
vi.mock("../../../services/search/file-search")
vi.mock("../../ignore/RooIgnoreController")
import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
import { searchWorkspaceFiles } from "../../../services/search/file-search"
import { RooIgnoreController } from "../../ignore/RooIgnoreController"
const mockSearchWorkspaceFiles = searchWorkspaceFiles as Mock<typeof searchWorkspaceFiles>
vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
showErrorMessage: vi.fn(),
},
workspace: {
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
},
}))
describe("webviewMessageHandler - searchFiles with RooIgnore filtering", () => {
let mockClineProvider: ClineProvider
let mockFilterPaths: Mock
let mockDispose: Mock
beforeEach(() => {
vi.clearAllMocks()
// Spy on the mock RooIgnoreController prototype methods
mockFilterPaths = vi.fn()
mockDispose = vi.fn()
// Override the filterPaths method on the prototype
;(RooIgnoreController.prototype as any).filterPaths = mockFilterPaths
;(RooIgnoreController.prototype as any).initialize = vi.fn().mockResolvedValue(undefined)
;(RooIgnoreController.prototype as any).dispose = mockDispose
// Create mock ClineProvider
mockClineProvider = {
getState: vi.fn(),
postMessageToWebview: vi.fn(),
getCurrentTask: vi.fn(),
cwd: "/mock/workspace",
} as unknown as ClineProvider
})
it("should filter results using RooIgnoreController when showRooIgnoredFiles is false", async () => {
// Setup mock results from file search
const mockResults = [
{ path: "src/index.ts", type: "file" as const, label: "index.ts" },
{ path: "secrets/config.json", type: "file" as const, label: "config.json" },
{ path: "src/utils.ts", type: "file" as const, label: "utils.ts" },
]
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
// Setup state with showRooIgnoredFiles = false
;(mockClineProvider.getState as Mock).mockResolvedValue({
showRooIgnoredFiles: false,
})
// Setup filter to exclude secrets folder
mockFilterPaths.mockReturnValue(["src/index.ts", "src/utils.ts"])
// No current task, so temporary controller will be created
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "index",
requestId: "test-request-123",
})
// Verify filterPaths was called with all result paths
expect(mockFilterPaths).toHaveBeenCalledWith(["src/index.ts", "secrets/config.json", "src/utils.ts"])
// Verify filtered results were sent to webview
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "fileSearchResults",
results: [
{ path: "src/index.ts", type: "file", label: "index.ts" },
{ path: "src/utils.ts", type: "file", label: "utils.ts" },
],
requestId: "test-request-123",
})
})
it("should not filter results when showRooIgnoredFiles is true", async () => {
// Setup mock results from file search
const mockResults = [
{ path: "src/index.ts", type: "file" as const, label: "index.ts" },
{ path: "secrets/config.json", type: "file" as const, label: "config.json" },
]
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
// Setup state with showRooIgnoredFiles = true
;(mockClineProvider.getState as Mock).mockResolvedValue({
showRooIgnoredFiles: true,
})
// No current task
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "index",
requestId: "test-request-456",
})
// Verify filterPaths was NOT called
expect(mockFilterPaths).not.toHaveBeenCalled()
// Verify all results were sent to webview (unfiltered)
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "fileSearchResults",
results: mockResults,
requestId: "test-request-456",
})
})
it("should use existing RooIgnoreController from current task", async () => {
// Setup mock results from file search
const mockResults = [
{ path: "src/index.ts", type: "file" as const, label: "index.ts" },
{ path: "private/secret.ts", type: "file" as const, label: "secret.ts" },
]
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
// Setup state with showRooIgnoredFiles = false
;(mockClineProvider.getState as Mock).mockResolvedValue({
showRooIgnoredFiles: false,
})
// Create a mock task with its own RooIgnoreController
const taskFilterPaths = vi.fn().mockReturnValue(["src/index.ts"])
const taskRooIgnoreController = {
filterPaths: taskFilterPaths,
initialize: vi.fn(),
}
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue({
taskId: "test-task-id",
rooIgnoreController: taskRooIgnoreController,
})
await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "index",
requestId: "test-request-789",
})
// Verify the task's controller was used (not the prototype)
expect(taskFilterPaths).toHaveBeenCalledWith(["src/index.ts", "private/secret.ts"])
// Verify filtered results were sent to webview
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "fileSearchResults",
results: [{ path: "src/index.ts", type: "file", label: "index.ts" }],
requestId: "test-request-789",
})
})
it("should handle error when no workspace path is available", async () => {
// Create provider without cwd
mockClineProvider = {
...mockClineProvider,
cwd: undefined,
getCurrentTask: vi.fn().mockReturnValue(null),
} as unknown as ClineProvider
await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "test",
requestId: "test-request-error",
})
// Verify error response was sent
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "fileSearchResults",
results: [],
requestId: "test-request-error",
error: "No workspace path available",
})
})
it("should handle errors from searchWorkspaceFiles", async () => {
mockSearchWorkspaceFiles.mockRejectedValue(new Error("File search failed"))
// Setup state
;(mockClineProvider.getState as Mock).mockResolvedValue({
showRooIgnoredFiles: false,
})
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "test",
requestId: "test-request-fail",
})
// Verify error response was sent
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "fileSearchResults",
results: [],
error: "File search failed",
requestId: "test-request-fail",
})
})
it("should default showRooIgnoredFiles to false when state is null", async () => {
// Setup mock results from file search
const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }]
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
// Setup state to return null
;(mockClineProvider.getState as Mock).mockResolvedValue(null)
// Setup filter to return all paths (no filtering)
mockFilterPaths.mockReturnValue(["src/index.ts"])
// No current task
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "index",
requestId: "test-request-default",
})
// Verify filterPaths was called (showRooIgnoredFiles defaults to false)
expect(mockFilterPaths).toHaveBeenCalled()
})
it("should dispose temporary RooIgnoreController after use", async () => {
// Setup mock results from file search
const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }]
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
// Setup state
;(mockClineProvider.getState as Mock).mockResolvedValue({
showRooIgnoredFiles: false,
})
// Setup filter
mockFilterPaths.mockReturnValue(["src/index.ts"])
// No current task, so temporary controller will be created and should be disposed
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "index",
requestId: "test-request-dispose",
})
// Verify dispose was called on the temporary controller
expect(mockDispose).toHaveBeenCalled()
})
it("should not dispose controller from current task", async () => {
// Setup mock results from file search
const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }]
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
// Setup state
;(mockClineProvider.getState as Mock).mockResolvedValue({
showRooIgnoredFiles: false,
})
// Create a mock task with its own RooIgnoreController
const taskFilterPaths = vi.fn().mockReturnValue(["src/index.ts"])
const taskDispose = vi.fn()
const taskRooIgnoreController = {
filterPaths: taskFilterPaths,
initialize: vi.fn(),
dispose: taskDispose,
}
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue({
taskId: "test-task-id",
rooIgnoreController: taskRooIgnoreController,
})
await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "index",
requestId: "test-request-no-dispose",
})
// Verify dispose was NOT called on the task's controller
expect(taskDispose).not.toHaveBeenCalled()
// Verify the prototype dispose was also not called
expect(mockDispose).not.toHaveBeenCalled()
})
})

View file

@ -118,6 +118,15 @@ vi.mock("../../../utils/fs")
vi.mock("../../../utils/path")
vi.mock("../../../utils/globalContext")
vi.mock("../../mentions/resolveImageMentions", () => ({
resolveImageMentions: vi.fn(async ({ text, images }: { text: string; images?: string[] }) => ({
text,
images: [...(images ?? []), "data:image/png;base64,from-mention"],
})),
}))
import { resolveImageMentions } from "../../mentions/resolveImageMentions"
describe("webviewMessageHandler - requestLmStudioModels", () => {
beforeEach(() => {
vi.clearAllMocks()
@ -160,6 +169,37 @@ describe("webviewMessageHandler - requestLmStudioModels", () => {
})
})
describe("webviewMessageHandler - image mentions", () => {
beforeEach(() => {
vi.clearAllMocks()
mockClineProvider.getState = vi.fn().mockResolvedValue({
maxImageFileSize: 5,
maxTotalImageSize: 20,
})
})
it("should resolve image mentions for askResponse payloads", async () => {
const mockHandleWebviewAskResponse = vi.fn()
vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({
cwd: "/mock/workspace",
rooIgnoreController: undefined,
handleWebviewAskResponse: mockHandleWebviewAskResponse,
} as any)
await webviewMessageHandler(mockClineProvider, {
type: "askResponse",
askResponse: "messageResponse",
text: "See @/img.png",
images: [],
})
expect(vi.mocked(resolveImageMentions)).toHaveBeenCalled()
expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "See @/img.png", [
"data:image/png;base64,from-mention",
])
})
})
describe("webviewMessageHandler - requestOllamaModels", () => {
beforeEach(() => {
vi.clearAllMocks()

View file

@ -53,6 +53,8 @@ import { exportSettings, importSettingsWithFeedback } from "../config/importExpo
import { getOpenAiModels } from "../../api/providers/openai"
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
import { openMention } from "../mentions"
import { resolveImageMentions } from "../mentions/resolveImageMentions"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { getWorkspacePath } from "../../utils/path"
import { Mode, defaultModeSlug } from "../../shared/modes"
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
@ -78,6 +80,26 @@ export const webviewMessageHandler = async (
const getCurrentCwd = () => {
return provider.getCurrentTask()?.cwd || provider.cwd
}
/**
* Resolves image file mentions in incoming messages.
* Matches read_file behavior: respects size limits and model capabilities.
*/
const resolveIncomingImages = async (payload: { text?: string; images?: string[] }) => {
const text = payload.text ?? ""
const images = payload.images
const currentTask = provider.getCurrentTask()
const state = await provider.getState()
const resolved = await resolveImageMentions({
text,
images,
cwd: getCurrentCwd(),
rooIgnoreController: currentTask?.rooIgnoreController,
maxImageFileSize: state.maxImageFileSize,
maxTotalImageSize: state.maxTotalImageSize,
})
return resolved
}
/**
* Shared utility to find message indices based on timestamp.
* When multiple messages share the same timestamp (e.g., after condense),
@ -504,7 +526,8 @@ export const webviewMessageHandler = async (
// agentically running promises in old instance don't affect our new
// task. This essentially creates a fresh slate for the new task.
try {
await provider.createTask(message.text, message.images)
const resolved = await resolveIncomingImages({ text: message.text, images: message.images })
await provider.createTask(resolved.text, resolved.images)
// Task created successfully - notify the UI to reset
await provider.postMessageToWebview({ type: "invoke", invoke: "newChat" })
} catch (error) {
@ -521,7 +544,12 @@ export const webviewMessageHandler = async (
break
case "askResponse":
provider.getCurrentTask()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
{
const resolved = await resolveIncomingImages({ text: message.text, images: message.images })
provider
.getCurrentTask()
?.handleWebviewAskResponse(message.askResponse!, resolved.text, resolved.images)
}
break
case "updateSettings":
@ -1707,12 +1735,39 @@ export const webviewMessageHandler = async (
20, // Use default limit, as filtering is now done in the backend
)
// Send results back to webview
await provider.postMessageToWebview({
type: "fileSearchResults",
results,
requestId: message.requestId,
})
// Get the RooIgnoreController from the current task, or create a new one
const currentTask = provider.getCurrentTask()
let rooIgnoreController = currentTask?.rooIgnoreController
let tempController: RooIgnoreController | undefined
// If no current task or no controller, create a temporary one
if (!rooIgnoreController) {
tempController = new RooIgnoreController(workspacePath)
await tempController.initialize()
rooIgnoreController = tempController
}
try {
// Get showRooIgnoredFiles setting from state
const { showRooIgnoredFiles = false } = (await provider.getState()) ?? {}
// Filter results using RooIgnoreController if showRooIgnoredFiles is false
let filteredResults = results
if (!showRooIgnoredFiles && rooIgnoreController) {
const allowedPaths = rooIgnoreController.filterPaths(results.map((r) => r.path))
filteredResults = results.filter((r) => allowedPaths.includes(r.path))
}
// Send results back to webview
await provider.postMessageToWebview({
type: "fileSearchResults",
results: filteredResults,
requestId: message.requestId,
})
} finally {
// Dispose temporary controller to prevent resource leak
tempController?.dispose()
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
@ -1876,11 +1931,12 @@ export const webviewMessageHandler = async (
break
case "editMessageConfirm":
if (message.messageTs && message.text) {
const resolved = await resolveIncomingImages({ text: message.text, images: message.images })
await handleEditMessageConfirm(
message.messageTs,
message.text,
resolved.text,
message.restoreCheckpoint,
message.images,
resolved.images,
)
}
break
@ -3059,7 +3115,8 @@ export const webviewMessageHandler = async (
*/
case "queueMessage": {
provider.getCurrentTask()?.messageQueueService.addMessage(message.text ?? "", message.images)
const resolved = await resolveIncomingImages({ text: message.text, images: message.images })
provider.getCurrentTask()?.messageQueueService.addMessage(resolved.text, resolved.images)
break
}
case "removeQueuedMessage": {

View file

@ -100,7 +100,10 @@ async function main() {
plugins,
entryPoints: ["extension.ts"],
outfile: "dist/extension.js",
external: ["vscode", "esbuild"],
// global-agent must be external because it dynamically patches Node.js http/https modules
// which breaks when bundled. It needs access to the actual Node.js module instances.
// undici must be bundled because our VSIX is packaged with `--no-dependencies`.
external: ["vscode", "esbuild", "global-agent"],
}
/**

View file

@ -19,6 +19,7 @@ import { customToolRegistry } from "@roo-code/core"
import "./utils/path" // Necessary to have access to String.prototype.toPosix.
import { createOutputChannelLogger, createDualLogger } from "./utils/outputChannelLogger"
import { initializeNetworkProxy } from "./utils/networkProxy"
import { Package } from "./shared/package"
import { formatLanguage } from "./shared/language"
@ -68,6 +69,11 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(outputChannel)
outputChannel.appendLine(`${Package.name} extension activated - ${JSON.stringify(Package)}`)
// Initialize network proxy configuration early, before any network requests.
// When proxyUrl is configured, all HTTP/HTTPS traffic will be routed through it.
// Only applied in debug mode (F5).
await initializeNetworkProxy(context, outputChannel)
// Set extension path for custom tool registry to find bundled esbuild
customToolRegistry.setExtensionPath(context.extensionPath)

View file

@ -3,7 +3,7 @@
"displayName": "%extension.displayName%",
"description": "%extension.description%",
"publisher": "RooVeterinaryInc",
"version": "3.38.3",
"version": "3.39.0",
"icon": "assets/icons/icon.png",
"galleryBanner": {
"color": "#617A91",
@ -411,6 +411,24 @@
"type": "boolean",
"default": false,
"description": "%settings.debug.description%"
},
"roo-cline.debugProxy.enabled": {
"type": "boolean",
"default": false,
"description": "%settings.debugProxy.enabled.description%",
"markdownDescription": "%settings.debugProxy.enabled.description%"
},
"roo-cline.debugProxy.serverUrl": {
"type": "string",
"default": "http://127.0.0.1:8888",
"description": "%settings.debugProxy.serverUrl.description%",
"markdownDescription": "%settings.debugProxy.serverUrl.description%"
},
"roo-cline.debugProxy.tlsInsecure": {
"type": "boolean",
"default": false,
"description": "%settings.debugProxy.tlsInsecure.description%",
"markdownDescription": "%settings.debugProxy.tlsInsecure.description%"
}
}
}
@ -461,6 +479,7 @@
"fastest-levenshtein": "^1.0.16",
"fzf": "^0.5.2",
"get-folder-size": "^5.0.0",
"global-agent": "^3.0.0",
"google-auth-library": "^9.15.1",
"gray-matter": "^4.0.3",
"i18next": "^25.0.0",
@ -503,6 +522,7 @@
"tmp": "^0.2.3",
"tree-sitter-wasms": "^0.1.12",
"turndown": "^7.2.0",
"undici": "^6.21.3",
"uuid": "^11.1.0",
"vscode-material-icons": "^0.1.1",
"web-tree-sitter": "^0.25.6",

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Temps màxim en segons per esperar les respostes de l'API (0 = sense temps d'espera, 1-3600s, per defecte: 600s). Es recomanen valors més alts per a proveïdors locals com LM Studio i Ollama que poden necessitar més temps de processament.",
"settings.newTaskRequireTodos.description": "Requerir el paràmetre de tasques pendents quan es creïn noves tasques amb l'eina new_task",
"settings.codeIndex.embeddingBatchSize.description": "La mida del lot per a operacions d'incrustació durant la indexació de codi. Ajusta això segons els límits del teu proveïdor d'API. Per defecte és 60.",
"settings.debug.description": "Activa el mode de depuració per mostrar botons addicionals per veure l'historial de conversa de l'API i els missatges de la interfície d'usuari com a JSON embellert en fitxers temporals."
"settings.debug.description": "Activa el mode de depuració per mostrar botons addicionals per veure l'historial de conversa de l'API i els missatges de la interfície d'usuari com a JSON embellert en fitxers temporals.",
"settings.debugProxy.enabled.description": "**Habilita el Debug Proxy** — Redirigeix totes les sol·licituds de xarxa sortints a través d'un proxy per a debugging MITM. Només està actiu quan s'executa en mode debug (F5).",
"settings.debugProxy.serverUrl.description": "URL del proxy (p. ex., `http://127.0.0.1:8888`). Només s'utilitza quan el **Debug Proxy** està habilitat.",
"settings.debugProxy.tlsInsecure.description": "Accepta certificats auto-signats del proxy. **Requerit per a la inspecció MITM.** ⚠️ Insegur — utilitza-ho només per a debugging local."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Maximale Wartezeit in Sekunden auf API-Antworten (0 = kein Timeout, 1-3600s, Standard: 600s). Höhere Werte werden für lokale Anbieter wie LM Studio und Ollama empfohlen, die möglicherweise mehr Verarbeitungszeit benötigen.",
"settings.newTaskRequireTodos.description": "Todos-Parameter beim Erstellen neuer Aufgaben mit dem new_task-Tool erfordern",
"settings.codeIndex.embeddingBatchSize.description": "Die Batch-Größe für Embedding-Operationen während der Code-Indexierung. Passe dies an die Limits deines API-Anbieters an. Standard ist 60.",
"settings.debug.description": "Aktiviere den Debug-Modus, um zusätzliche Schaltflächen zum Anzeigen des API-Konversationsverlaufs und der UI-Nachrichten als formatiertes JSON in temporären Dateien anzuzeigen."
"settings.debug.description": "Aktiviere den Debug-Modus, um zusätzliche Schaltflächen zum Anzeigen des API-Konversationsverlaufs und der UI-Nachrichten als formatiertes JSON in temporären Dateien anzuzeigen.",
"settings.debugProxy.enabled.description": "**Debug-Proxy aktivieren** — Leite alle ausgehenden Netzwerkanfragen über einen Proxy für MITM-Debugging. Nur aktiv, wenn du im Debug-Modus (F5) läufst.",
"settings.debugProxy.serverUrl.description": "Proxy-URL (z. B. `http://127.0.0.1:8888`). Wird nur verwendet, wenn der **Debug-Proxy** aktiviert ist.",
"settings.debugProxy.tlsInsecure.description": "Akzeptiere selbstsignierte Zertifikate vom Proxy. **Erforderlich für MITM-Inspektion.** ⚠️ Unsicher – verwende das nur für lokales Debugging."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Tiempo máximo en segundos de espera para las respuestas de la API (0 = sin tiempo de espera, 1-3600s, por defecto: 600s). Se recomiendan valores más altos para proveedores locales como LM Studio y Ollama que puedan necesitar más tiempo de procesamiento.",
"settings.newTaskRequireTodos.description": "Requerir el parámetro todos al crear nuevas tareas con la herramienta new_task",
"settings.codeIndex.embeddingBatchSize.description": "El tamaño del lote para operaciones de embedding durante la indexación de código. Ajusta esto según los límites de tu proveedor de API. Por defecto es 60.",
"settings.debug.description": "Activa el modo de depuración para mostrar botones adicionales para ver el historial de conversación de API y los mensajes de la interfaz de usuario como JSON embellecido en archivos temporales."
"settings.debug.description": "Activa el modo de depuración para mostrar botones adicionales para ver el historial de conversación de API y los mensajes de la interfaz de usuario como JSON embellecido en archivos temporales.",
"settings.debugProxy.enabled.description": "**Activar Debug Proxy** — Redirige todas las solicitudes de red salientes a través de un proxy para depuración MITM. Solo está activo cuando se ejecuta en modo depuración (F5).",
"settings.debugProxy.serverUrl.description": "URL del proxy (p. ej., `http://127.0.0.1:8888`). Solo se usa cuando **Debug Proxy** está activado.",
"settings.debugProxy.tlsInsecure.description": "Aceptar certificados autofirmados del proxy. **Necesario para la inspección MITM.** ⚠️ Inseguro: úsalo solo para depuración local."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Temps maximum en secondes d'attente pour les réponses de l'API (0 = pas de timeout, 1-3600s, par défaut : 600s). Des valeurs plus élevées sont recommandées pour les fournisseurs locaux comme LM Studio et Ollama qui peuvent nécessiter plus de temps de traitement.",
"settings.newTaskRequireTodos.description": "Exiger le paramètre todos lors de la création de nouvelles tâches avec l'outil new_task",
"settings.codeIndex.embeddingBatchSize.description": "La taille du lot pour les opérations d'embedding lors de l'indexation du code. Ajustez ceci selon les limites de votre fournisseur d'API. Par défaut, c'est 60.",
"settings.debug.description": "Active le mode debug pour afficher des boutons supplémentaires permettant de visualiser l'historique de conversation de l'API et les messages de l'interface utilisateur sous forme de JSON formaté dans des fichiers temporaires."
"settings.debug.description": "Active le mode debug pour afficher des boutons supplémentaires permettant de visualiser l'historique de conversation de l'API et les messages de l'interface utilisateur sous forme de JSON formaté dans des fichiers temporaires.",
"settings.debugProxy.enabled.description": "**Activer le Debug Proxy** — Redirige toutes les requêtes réseau sortantes via un proxy pour le debug MITM. Actif uniquement quand tu es en mode debug (F5).",
"settings.debugProxy.serverUrl.description": "URL du proxy (par ex. `http://127.0.0.1:8888`). Utilisée uniquement quand le **Debug Proxy** est activé.",
"settings.debugProxy.tlsInsecure.description": "Accepter les certificats auto-signés du proxy. **Requis pour l'inspection MITM.** ⚠️ Non sécurisé — à utiliser uniquement pour le debug local."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "एपीआई प्रतिक्रियाओं की प्रतीक्षा करने के लिए सेकंड में अधिकतम समय (0 = कोई टाइमआउट नहीं, 1-3600s, डिफ़ॉल्ट: 600s)। एलएम स्टूडियो और ओलामा जैसे स्थानीय प्रदाताओं के लिए उच्च मानों की सिफारिश की जाती है जिन्हें अधिक प्रसंस्करण समय की आवश्यकता हो सकती है।",
"settings.newTaskRequireTodos.description": "new_task टूल के साथ नए कार्य बनाते समय टूडू पैरामीटर की आवश्यकता होती है",
"settings.codeIndex.embeddingBatchSize.description": "कोड इंडेक्सिंग के दौरान एम्बेडिंग ऑपरेशन के लिए बैच साइज़। इसे अपने API प्रदाता की सीमाओं के अनुसार समायोजित करें। डिफ़ॉल्ट 60 है।",
"settings.debug.description": "API conversation history और UI messages को temporary files में prettified JSON के रूप में देखने के लिए अतिरिक्त बटन दिखाने के लिए debug mode सक्षम करें।"
"settings.debug.description": "API conversation history और UI messages को temporary files में prettified JSON के रूप में देखने के लिए अतिरिक्त बटन दिखाने के लिए debug mode सक्षम करें।",
"settings.debugProxy.enabled.description": "**Debug Proxy सक्षम करो** — सभी आउटबाउंड network requests को MITM debugging के लिए proxy के ज़रिए route करो। सिर्फ तब active रहेगा जब तुम debug mode (F5) में चला रहे हो।",
"settings.debugProxy.serverUrl.description": "Proxy URL (जैसे `http://127.0.0.1:8888`)। सिर्फ तब इस्तेमाल होती है जब **Debug Proxy** enabled हो।",
"settings.debugProxy.tlsInsecure.description": "Proxy से आने वाले self-signed certificates accept करो। **MITM inspection के लिए ज़रूरी।** ⚠️ Insecure — सिर्फ local debugging के लिए इस्तेमाल करो।"
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Waktu maksimum dalam detik untuk menunggu respons API (0 = tidak ada batas waktu, 1-3600s, default: 600s). Nilai yang lebih tinggi disarankan untuk penyedia lokal seperti LM Studio dan Ollama yang mungkin memerlukan lebih banyak waktu pemrosesan.",
"settings.newTaskRequireTodos.description": "Memerlukan parameter todos saat membuat tugas baru dengan alat new_task",
"settings.codeIndex.embeddingBatchSize.description": "Ukuran batch untuk operasi embedding selama pengindeksan kode. Sesuaikan ini berdasarkan batas penyedia API kamu. Default adalah 60.",
"settings.debug.description": "Aktifkan mode debug untuk menampilkan tombol tambahan untuk melihat riwayat percakapan API dan pesan UI sebagai JSON yang diformat dalam file sementara."
"settings.debug.description": "Aktifkan mode debug untuk menampilkan tombol tambahan untuk melihat riwayat percakapan API dan pesan UI sebagai JSON yang diformat dalam file sementara.",
"settings.debugProxy.enabled.description": "**Aktifkan Debug Proxy** — Arahkan semua permintaan jaringan keluar lewat proxy untuk debugging MITM. Hanya aktif saat kamu berjalan dalam mode debug (F5).",
"settings.debugProxy.serverUrl.description": "URL proxy (mis. `http://127.0.0.1:8888`). Hanya digunakan ketika **Debug Proxy** diaktifkan.",
"settings.debugProxy.tlsInsecure.description": "Terima sertifikat self-signed dari proxy. **Diperlukan untuk inspeksi MITM.** ⚠️ Tidak aman — gunakan hanya untuk debugging lokal."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Tempo massimo in secondi di attesa per le risposte API (0 = nessun timeout, 1-3600s, predefinito: 600s). Valori più alti sono consigliati per provider locali come LM Studio e Ollama che potrebbero richiedere più tempo di elaborazione.",
"settings.newTaskRequireTodos.description": "Richiedere il parametro todos quando si creano nuove attività con lo strumento new_task",
"settings.codeIndex.embeddingBatchSize.description": "La dimensione del batch per le operazioni di embedding durante l'indicizzazione del codice. Regola questo in base ai limiti del tuo provider API. Il valore predefinito è 60.",
"settings.debug.description": "Abilita la modalità debug per mostrare pulsanti aggiuntivi per visualizzare la cronologia delle conversazioni API e i messaggi dell'interfaccia utente come JSON formattato in file temporanei."
"settings.debug.description": "Abilita la modalità debug per mostrare pulsanti aggiuntivi per visualizzare la cronologia delle conversazioni API e i messaggi dell'interfaccia utente come JSON formattato in file temporanei.",
"settings.debugProxy.enabled.description": "**Abilita Debug Proxy** — Instrada tutte le richieste di rete in uscita tramite un proxy per il debugging MITM. Attivo solo quando esegui in modalità debug (F5).",
"settings.debugProxy.serverUrl.description": "URL del proxy (ad es. `http://127.0.0.1:8888`). Usato solo quando **Debug Proxy** è abilitato.",
"settings.debugProxy.tlsInsecure.description": "Accetta certificati autofirmati dal proxy. **Necessario per l'ispezione MITM.** ⚠️ Non sicuro — usalo solo per il debugging locale."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "API応答を待機する最大時間(秒)(0 = タイムアウトなし、1-3600秒、デフォルト: 600秒)。LM StudioやOllamaのような、より多くの処理時間を必要とする可能性のあるローカルプロバイダーには、より高い値が推奨されます。",
"settings.newTaskRequireTodos.description": "new_taskツールで新しいタスクを作成する際にtodosパラメータを必須にする",
"settings.codeIndex.embeddingBatchSize.description": "コードインデックス作成中のエンベディング操作のバッチサイズ。APIプロバイダーの制限に基づいてこれを調整してください。デフォルトは60です。",
"settings.debug.description": "デバッグモードを有効にして、API会話履歴とUIメッセージをフォーマットされたJSONとして一時ファイルで表示するための追加ボタンを表示します。"
"settings.debug.description": "デバッグモードを有効にして、API会話履歴とUIメッセージをフォーマットされたJSONとして一時ファイルで表示するための追加ボタンを表示します。",
"settings.debugProxy.enabled.description": "**Debug Proxy を有効化** — すべての送信ネットワーク要求を MITM デバッグのためにプロキシ経由でルーティングします。デバッグモード (F5) で実行しているときだけ有効です。",
"settings.debugProxy.serverUrl.description": "プロキシ URL(例: `http://127.0.0.1:8888`)。**Debug Proxy** が有効なときにだけ使用されます。",
"settings.debugProxy.tlsInsecure.description": "プロキシからの自己署名証明書を許可します。**MITM インスペクションに必須です。** ⚠️ 危険な設定なので、ローカルでのデバッグにだけ使用してください。"
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Maximum time in seconds to wait for API responses (0 = no timeout, 1-3600s, default: 600s). Higher values are recommended for local providers like LM Studio and Ollama that may need more processing time.",
"settings.newTaskRequireTodos.description": "Require todos parameter when creating new tasks with the new_task tool",
"settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60.",
"settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files."
"settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files.",
"settings.debugProxy.enabled.description": "**Enable Debug Proxy** — Route all outbound network requests through a proxy for MITM debugging. Only active when running in debug mode (F5).",
"settings.debugProxy.serverUrl.description": "Proxy URL (e.g., `http://127.0.0.1:8888`). Only used when **Debug Proxy** is enabled.",
"settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "API 응답을 기다리는 최대 시간(초) (0 = 시간 초과 없음, 1-3600초, 기본값: 600초). 더 많은 처리 시간이 필요할 수 있는 LM Studio 및 Ollama와 같은 로컬 공급자에게는 더 높은 값을 사용하는 것이 좋습니다.",
"settings.newTaskRequireTodos.description": "new_task 도구로 새 작업을 생성할 때 todos 매개변수 필요",
"settings.codeIndex.embeddingBatchSize.description": "코드 인덱싱 중 임베딩 작업의 배치 크기입니다. API 공급자의 제한에 따라 이를 조정하세요. 기본값은 60입니다.",
"settings.debug.description": "디버그 모드를 활성화하여 API 대화 기록과 UI 메시지를 임시 파일에 포맷된 JSON으로 보기 위한 추가 버튼을 표시합니다."
"settings.debug.description": "디버그 모드를 활성화하여 API 대화 기록과 UI 메시지를 임시 파일에 포맷된 JSON으로 보기 위한 추가 버튼을 표시합니다.",
"settings.debugProxy.enabled.description": "**Debug Proxy 활성화** — 모든 아웃바운드 네트워크 요청을 MITM 디버깅을 위해 프록시를 통해 라우팅합니다. 디버그 모드(F5)로 실행 중일 때만 활성화됩니다.",
"settings.debugProxy.serverUrl.description": "프록시 URL(예: `http://127.0.0.1:8888`). **Debug Proxy** 가 활성화된 경우에만 사용됩니다.",
"settings.debugProxy.tlsInsecure.description": "프록시의 self-signed 인증서를 허용합니다. **MITM 검사에 필요합니다.** ⚠️ 안전하지 않으므로 로컬 디버깅에만 사용하세요."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Maximale tijd in seconden om te wachten op API-reacties (0 = geen time-out, 1-3600s, standaard: 600s). Hogere waarden worden aanbevolen voor lokale providers zoals LM Studio en Ollama die mogelijk meer verwerkingstijd nodig hebben.",
"settings.newTaskRequireTodos.description": "Todos-parameter vereisen bij het maken van nieuwe taken met de new_task tool",
"settings.codeIndex.embeddingBatchSize.description": "De batchgrootte voor embedding-operaties tijdens code-indexering. Pas dit aan op basis van de limieten van je API-provider. Standaard is 60.",
"settings.debug.description": "Schakel debug-modus in om extra knoppen te tonen voor het bekijken van API-conversatiegeschiedenis en UI-berichten als opgemaakte JSON in tijdelijke bestanden."
"settings.debug.description": "Schakel debug-modus in om extra knoppen te tonen voor het bekijken van API-conversatiegeschiedenis en UI-berichten als opgemaakte JSON in tijdelijke bestanden.",
"settings.debugProxy.enabled.description": "**Debug Proxy inschakelen** — Leid alle uitgaande netwerkverzoeken via een proxy voor MITM-debugging. Alleen actief wanneer je in debugmodus (F5) draait.",
"settings.debugProxy.serverUrl.description": "Proxy-URL (bijv. `http://127.0.0.1:8888`). Wordt alleen gebruikt wanneer **Debug Proxy** is ingeschakeld.",
"settings.debugProxy.tlsInsecure.description": "Accepteer zelfondertekende certificaten van de proxy. **Vereist voor MITM-inspectie.** ⚠️ Onveilig — gebruik dit alleen voor lokale debugging."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Maksymalny czas w sekundach oczekiwania na odpowiedzi API (0 = brak limitu czasu, 1-3600s, domyślnie: 600s). Wyższe wartości są zalecane dla lokalnych dostawców, takich jak LM Studio i Ollama, którzy mogą potrzebować więcej czasu na przetwarzanie.",
"settings.newTaskRequireTodos.description": "Wymagaj parametru todos podczas tworzenia nowych zadań za pomocą narzędzia new_task",
"settings.codeIndex.embeddingBatchSize.description": "Rozmiar partii dla operacji osadzania podczas indeksowania kodu. Dostosuj to w oparciu o limity twojego dostawcy API. Domyślnie to 60.",
"settings.debug.description": "Włącz tryb debugowania, aby wyświetlić dodatkowe przyciski do przeglądania historii rozmów API i komunikatów interfejsu użytkownika jako sformatowany JSON w plikach tymczasowych."
"settings.debug.description": "Włącz tryb debugowania, aby wyświetlić dodatkowe przyciski do przeglądania historii rozmów API i komunikatów interfejsu użytkownika jako sformatowany JSON w plikach tymczasowych.",
"settings.debugProxy.enabled.description": "**Włącz Debug Proxy** — Kieruj wszystkie wychodzące żądania sieciowe przez proxy na potrzeby debugowania MITM. Aktywne tylko wtedy, gdy uruchamiasz w trybie debugowania (F5).",
"settings.debugProxy.serverUrl.description": "URL proxy (np. `http://127.0.0.1:8888`). Używany tylko wtedy, gdy **Debug Proxy** jest włączony.",
"settings.debugProxy.tlsInsecure.description": "Akceptuj certyfikaty self-signed z proxy. **Wymagane do inspekcji MITM.** ⚠️ Niezabezpieczone — używaj tylko do lokalnego debugowania."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Tempo máximo em segundos de espera pelas respostas da API (0 = sem tempo limite, 1-3600s, padrão: 600s). Valores mais altos são recomendados para provedores locais como LM Studio e Ollama que podem precisar de mais tempo de processamento.",
"settings.newTaskRequireTodos.description": "Exigir parâmetro todos ao criar novas tarefas com a ferramenta new_task",
"settings.codeIndex.embeddingBatchSize.description": "O tamanho do lote para operações de embedding durante a indexação de código. Ajuste isso com base nos limites do seu provedor de API. O padrão é 60.",
"settings.debug.description": "Ativa o modo de depuração para mostrar botões adicionais para visualizar o histórico de conversas da API e mensagens da interface como JSON formatado em arquivos temporários."
"settings.debug.description": "Ativa o modo de depuração para mostrar botões adicionais para visualizar o histórico de conversas da API e mensagens da interface como JSON formatado em arquivos temporários.",
"settings.debugProxy.enabled.description": "**Ativar Debug Proxy** — Redireciona todas as solicitações de rede de saída por meio de um proxy para depuração MITM. Só fica ativo quando você está executando em modo de depuração (F5).",
"settings.debugProxy.serverUrl.description": "URL do proxy (por exemplo, `http://127.0.0.1:8888`). Só é usada quando o **Debug Proxy** está ativado.",
"settings.debugProxy.tlsInsecure.description": "Aceitar certificados self-signed do proxy. **Necessário para inspeção MITM.** ⚠️ Inseguro — use apenas para depuração local."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Максимальное время в секундах для ожидания ответов API (0 = нет тайм-аута, 1-3600 с, по умолчанию: 600 с). Рекомендуются более высокие значения для локальных провайдеров, таких как LM Studio и Ollama, которым может потребоваться больше времени на обработку.",
"settings.newTaskRequireTodos.description": "Требовать параметр todos при создании новых задач с помощью инструмента new_task",
"settings.codeIndex.embeddingBatchSize.description": "Размер пакета для операций встраивания во время индексации кода. Настройте это в соответствии с ограничениями вашего API-провайдера. По умолчанию 60.",
"settings.debug.description": "Включить режим отладки, чтобы отображать дополнительные кнопки для просмотра истории разговоров API и сообщений интерфейса в виде форматированного JSON во временных файлах."
"settings.debug.description": "Включить режим отладки, чтобы отображать дополнительные кнопки для просмотра истории разговоров API и сообщений интерфейса в виде форматированного JSON во временных файлах.",
"settings.debugProxy.enabled.description": "**Включить Debug Proxy** — направлять все исходящие сетевые запросы через прокси для MITM-отладки. Активен только когда ты запускаешь расширение в режиме отладки (F5).",
"settings.debugProxy.serverUrl.description": "URL прокси (например, `http://127.0.0.1:8888`). Используется только если **Debug Proxy** включён.",
"settings.debugProxy.tlsInsecure.description": "Принимать self-signed сертификаты от прокси. **Требуется для MITM-инспекции.** ⚠️ Небезопасно — используй только для локальной отладки."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "API yanıtları için beklenecek maksimum süre (saniye cinsinden) (0 = zaman aşımı yok, 1-3600s, varsayılan: 600s). LM Studio ve Ollama gibi daha fazla işlem süresi gerektirebilecek yerel sağlayıcılar için daha yüksek değerler önerilir.",
"settings.newTaskRequireTodos.description": "new_task aracıyla yeni görevler oluştururken todos parametresini gerekli kıl",
"settings.codeIndex.embeddingBatchSize.description": "Kod indeksleme sırasında gömme işlemleri için toplu iş boyutu. Bunu API sağlayıcınızın sınırlarına göre ayarlayın. Varsayılan 60'tır.",
"settings.debug.description": "API konuşma geçmişini ve kullanıcı arayüzü mesajlarını geçici dosyalarda biçimlendirilmiş JSON olarak görüntülemek için ek düğmeler göstermek üzere hata ayıklama modunu etkinleştir."
"settings.debug.description": "API konuşma geçmişini ve kullanıcı arayüzü mesajlarını geçici dosyalarda biçimlendirilmiş JSON olarak görüntülemek için ek düğmeler göstermek üzere hata ayıklama modunu etkinleştir.",
"settings.debugProxy.enabled.description": "**Debug Proxy'yi etkinleştir** — Tüm giden ağ isteklerini MITM hata ayıklaması için bir proxy üzerinden yönlendir. Yalnızca debug modunda (F5) çalıştırırken aktiftir.",
"settings.debugProxy.serverUrl.description": "Proxy URL'si (ör. `http://127.0.0.1:8888`). Yalnızca **Debug Proxy** etkin olduğunda kullanılır.",
"settings.debugProxy.tlsInsecure.description": "Proxy'den gelen self-signed sertifikaları kabul et. **MITM incelemesi için gerekli.** ⚠️ Güvensiz — yalnızca lokal debugging için kullan."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Thời gian tối đa tính bằng giây để đợi phản hồi API (0 = không có thời gian chờ, 1-3600 giây, mặc định: 600 giây). Nên sử dụng các giá trị cao hơn cho các nhà cung cấp cục bộ như LM Studio và Ollama có thể cần thêm thời gian xử lý.",
"settings.newTaskRequireTodos.description": "Yêu cầu tham số todos khi tạo nhiệm vụ mới với công cụ new_task",
"settings.codeIndex.embeddingBatchSize.description": "Kích thước lô cho các hoạt động nhúng trong quá trình lập chỉ mục mã. Điều chỉnh điều này dựa trên giới hạn của nhà cung cấp API của bạn. Mặc định là 60.",
"settings.debug.description": "Bật chế độ gỡ lỗi để hiển thị các nút bổ sung để xem lịch sử hội thoại API và thông điệp giao diện người dùng dưới dạng JSON được định dạng trong các tệp tạm thời."
"settings.debug.description": "Bật chế độ gỡ lỗi để hiển thị các nút bổ sung để xem lịch sử hội thoại API và thông điệp giao diện người dùng dưới dạng JSON được định dạng trong các tệp tạm thời.",
"settings.debugProxy.enabled.description": "**Bật Debug Proxy** — Chuyển hướng tất cả yêu cầu mạng đi ra qua một proxy để debug MITM. Chỉ hoạt động khi bạn chạy ở chế độ gỡ lỗi (F5).",
"settings.debugProxy.serverUrl.description": "Proxy URL (vd: `http://127.0.0.1:8888`). Chỉ được dùng khi **Debug Proxy** được bật.",
"settings.debugProxy.tlsInsecure.description": "Chấp nhận chứng chỉ self-signed từ proxy. **Bắt buộc cho việc kiểm tra MITM.** ⚠️ Không an toàn — chỉ dùng cho debug cục bộ."
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "等待 API 响应的最长时间(秒)(0 = 无超时,1-3600秒,默认值:600秒)。对于像 LM Studio 和 Ollama 这样可能需要更多处理时间的本地提供商,建议使用更高的值。",
"settings.newTaskRequireTodos.description": "使用 new_task 工具创建新任务时需要 todos 参数",
"settings.codeIndex.embeddingBatchSize.description": "代码索引期间嵌入操作的批处理大小。根据 API 提供商的限制调整此设置。默认值为 60。",
"settings.debug.description": "启用调试模式以显示额外按钮,用于在临时文件中以格式化 JSON 查看 API 对话历史和 UI 消息。"
"settings.debug.description": "启用调试模式以显示额外按钮,用于在临时文件中以格式化 JSON 查看 API 对话历史和 UI 消息。",
"settings.debugProxy.enabled.description": "**启用 Debug Proxy** — 通过代理转发所有出站网络请求,用于 MITM 调试。只在调试模式 (F5) 运行时生效。",
"settings.debugProxy.serverUrl.description": "代理 URL(例如 `http://127.0.0.1:8888`)。仅在启用 **Debug Proxy** 时使用。",
"settings.debugProxy.tlsInsecure.description": "接受来自代理的 self-signed 证书。**MITM 检查所必需。** ⚠️ 不安全——只在本地调试时使用。"
}

View file

@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "等待 API 回應的最長時間(秒)(0 = 無超時,1-3600秒,預設值:600秒)。對於像 LM Studio 和 Ollama 這樣可能需要更多處理時間的本地提供商,建議使用更高的值。",
"settings.newTaskRequireTodos.description": "使用 new_task 工具建立新工作時需要 todos 參數",
"settings.codeIndex.embeddingBatchSize.description": "程式碼索引期間嵌入操作的批次大小。根據 API 提供商的限制調整此設定。預設值為 60。",
"settings.debug.description": "啟用偵錯模式以顯示額外按鈕,用於在暫存檔案中以格式化 JSON 檢視 API 對話歷史紀錄和使用者介面訊息。"
"settings.debug.description": "啟用偵錯模式以顯示額外按鈕,用於在暫存檔案中以格式化 JSON 檢視 API 對話歷史紀錄和使用者介面訊息。",
"settings.debugProxy.enabled.description": "**啟用 Debug Proxy** — 將所有出站網路要求透過代理進行路由,以進行 MITM 偵錯。只有在除錯模式 (F5) 執行時才會啟用。",
"settings.debugProxy.serverUrl.description": "代理 URL(例如 `http://127.0.0.1:8888`)。只有在啟用 **Debug Proxy** 時才會使用。",
"settings.debugProxy.tlsInsecure.description": "接受來自代理的 self-signed 憑證。**MITM 檢查所必需。** ⚠️ 不安全——只在本機偵錯時使用。"
}

47
src/types/global-agent.d.ts vendored Normal file
View file

@ -0,0 +1,47 @@
/**
* Type declarations for global-agent package.
*
* global-agent is a library that creates a global HTTP/HTTPS agent
* that routes all traffic through a specified proxy.
*
* @see https://github.com/gajus/global-agent
*/
declare module "global-agent" {
/**
* Bootstrap global-agent to intercept all HTTP/HTTPS requests.
*
* After calling this function, all outgoing HTTP/HTTPS requests
* from the Node.js process will be routed through the proxy
* specified by the GLOBAL_AGENT_HTTP_PROXY and GLOBAL_AGENT_HTTPS_PROXY
* environment variables.
*
* @returns void
*/
export function bootstrap(): void
/**
* Create a global agent with custom configuration.
*
* @param options Configuration options for the global agent
* @returns void
*/
export function createGlobalProxyAgent(options?: {
/**
* Environment variable namespace prefix.
* Default: "GLOBAL_AGENT_"
*/
environmentVariableNamespace?: string
/**
* Force global agent to be used for all HTTP/HTTPS requests.
* Default: true
*/
forceGlobalAgent?: boolean
/**
* Socket connection timeout in milliseconds.
*/
socketConnectionTimeout?: number
}): void
}

View file

@ -0,0 +1,308 @@
import * as vscode from "vscode"
import { initializeNetworkProxy, getProxyConfig, isProxyEnabled, isDebugMode } from "../networkProxy"
// Mock global-agent
vi.mock("global-agent", () => ({
bootstrap: vi.fn(),
}))
// Mock vscode
vi.mock("vscode", () => ({
workspace: {
getConfiguration: vi.fn(),
onDidChangeConfiguration: vi.fn(() => ({ dispose: vi.fn() })),
},
ExtensionMode: {
Development: 2,
Production: 1,
Test: 3,
},
}))
describe("networkProxy", () => {
let mockOutputChannel: vscode.OutputChannel
let mockConfig: { get: ReturnType<typeof vi.fn> }
// Helper to create mock context with configurable extensionMode
function createMockContext(mode: vscode.ExtensionMode = vscode.ExtensionMode.Production): vscode.ExtensionContext {
return {
extensionMode: mode,
subscriptions: [],
extensionPath: "/test/path",
globalState: {
get: vi.fn(),
update: vi.fn(),
keys: vi.fn().mockReturnValue([]),
setKeysForSync: vi.fn(),
},
workspaceState: {
get: vi.fn(),
update: vi.fn(),
keys: vi.fn().mockReturnValue([]),
},
secrets: {
get: vi.fn(),
store: vi.fn(),
delete: vi.fn(),
onDidChange: vi.fn(),
},
extensionUri: { fsPath: "/test/path" } as vscode.Uri,
globalStorageUri: { fsPath: "/test/global" } as vscode.Uri,
logUri: { fsPath: "/test/logs" } as vscode.Uri,
storageUri: { fsPath: "/test/storage" } as vscode.Uri,
storagePath: "/test/storage",
globalStoragePath: "/test/global",
logPath: "/test/logs",
asAbsolutePath: vi.fn((p) => `/test/path/${p}`),
environmentVariableCollection: {} as vscode.GlobalEnvironmentVariableCollection,
extension: {} as vscode.Extension<unknown>,
languageModelAccessInformation: {} as vscode.LanguageModelAccessInformation,
} as unknown as vscode.ExtensionContext
}
beforeEach(() => {
vi.clearAllMocks()
// Reset environment variables
delete process.env.GLOBAL_AGENT_HTTP_PROXY
delete process.env.GLOBAL_AGENT_HTTPS_PROXY
delete process.env.GLOBAL_AGENT_NO_PROXY
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED
mockConfig = {
get: vi.fn().mockReturnValue(""),
}
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(
mockConfig as unknown as vscode.WorkspaceConfiguration,
)
mockOutputChannel = {
appendLine: vi.fn(),
append: vi.fn(),
clear: vi.fn(),
show: vi.fn(),
hide: vi.fn(),
dispose: vi.fn(),
name: "Test",
replace: vi.fn(),
} as unknown as vscode.OutputChannel
})
describe("initializeNetworkProxy", () => {
it("should initialize without proxy when debugProxy.enabled is false", () => {
mockConfig.get.mockImplementation((key: string) => {
if (key === "debugProxy.enabled") return false
if (key === "debugProxy.serverUrl") return "http://127.0.0.1:8888"
return ""
})
const context = createMockContext()
void initializeNetworkProxy(context, mockOutputChannel)
expect(process.env.GLOBAL_AGENT_HTTP_PROXY).toBeUndefined()
expect(process.env.GLOBAL_AGENT_HTTPS_PROXY).toBeUndefined()
})
it("should configure proxy environment variables when debugProxy.enabled is true", () => {
mockConfig.get.mockImplementation((key: string) => {
if (key === "debugProxy.enabled") return true
if (key === "debugProxy.serverUrl") return "http://localhost:8080"
return ""
})
// Proxy is only applied in debug mode.
const context = createMockContext(vscode.ExtensionMode.Development)
void initializeNetworkProxy(context, mockOutputChannel)
expect(process.env.GLOBAL_AGENT_HTTP_PROXY).toBe("http://localhost:8080")
expect(process.env.GLOBAL_AGENT_HTTPS_PROXY).toBe("http://localhost:8080")
})
it("should not modify TLS settings in debug mode by default", () => {
mockConfig.get.mockImplementation((key: string) => {
if (key === "debugProxy.enabled") return true
if (key === "debugProxy.serverUrl") return "http://localhost:8080"
if (key === "debugProxy.tlsInsecure") return false
return ""
})
const context = createMockContext(vscode.ExtensionMode.Development)
void initializeNetworkProxy(context, mockOutputChannel)
expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined()
})
it("should disable TLS verification when tlsInsecure is enabled (debug mode only)", () => {
mockConfig.get.mockImplementation((key: string) => {
if (key === "debugProxy.enabled") return true
if (key === "debugProxy.serverUrl") return "http://localhost:8080"
if (key === "debugProxy.tlsInsecure") return true
return ""
})
const context = createMockContext(vscode.ExtensionMode.Development)
void initializeNetworkProxy(context, mockOutputChannel)
expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBe("0")
})
it("should register configuration change listener in debug mode", () => {
const context = createMockContext(vscode.ExtensionMode.Development)
void initializeNetworkProxy(context, mockOutputChannel)
expect(vscode.workspace.onDidChangeConfiguration).toHaveBeenCalled()
expect(context.subscriptions.length).toBeGreaterThan(0)
})
it("should not register listeners in production mode (early exit)", () => {
const context = createMockContext(vscode.ExtensionMode.Production)
void initializeNetworkProxy(context, mockOutputChannel)
expect(vscode.workspace.onDidChangeConfiguration).not.toHaveBeenCalled()
expect(context.subscriptions.length).toBe(0)
})
it("should not throw in non-debug mode if proxy deps are not installed", () => {
mockConfig.get.mockImplementation((key: string) => {
if (key === "debugProxy.enabled") return true
if (key === "debugProxy.serverUrl") return "http://localhost:8080"
return ""
})
const context = createMockContext(vscode.ExtensionMode.Production)
expect(() => {
void initializeNetworkProxy(context, mockOutputChannel)
}).not.toThrow()
})
})
describe("getProxyConfig", () => {
it("should return default config before initialization", () => {
// Reset the module to clear internal state
vi.resetModules()
const config = getProxyConfig()
expect(config.enabled).toBe(false)
expect(config.serverUrl).toBe("http://127.0.0.1:8888") // default value
expect(config.isDebugMode).toBe(false)
})
it("should return correct config after initialization", () => {
mockConfig.get.mockImplementation((key: string) => {
if (key === "debugProxy.enabled") return true
if (key === "debugProxy.serverUrl") return "http://proxy.example.com:3128"
if (key === "debugProxy.tlsInsecure") return true
return ""
})
const context = createMockContext(vscode.ExtensionMode.Production)
void initializeNetworkProxy(context, mockOutputChannel)
const config = getProxyConfig()
expect(config.enabled).toBe(true)
expect(config.serverUrl).toBe("http://proxy.example.com:3128")
expect(config.tlsInsecure).toBe(true)
expect(config.isDebugMode).toBe(false)
})
it("should trim whitespace from server URL", () => {
mockConfig.get.mockImplementation((key: string) => {
if (key === "debugProxy.serverUrl") return " http://proxy.example.com:3128 "
return ""
})
const context = createMockContext()
void initializeNetworkProxy(context, mockOutputChannel)
const config = getProxyConfig()
expect(config.serverUrl).toBe("http://proxy.example.com:3128")
})
it("should return default URL for empty server URL", () => {
mockConfig.get.mockImplementation((key: string) => {
if (key === "debugProxy.serverUrl") return " "
return ""
})
const context = createMockContext()
void initializeNetworkProxy(context, mockOutputChannel)
const config = getProxyConfig()
expect(config.serverUrl).toBe("http://127.0.0.1:8888") // falls back to default
})
})
describe("isProxyEnabled", () => {
it("should return false when proxy is not enabled", () => {
mockConfig.get.mockImplementation((key: string) => {
if (key === "debugProxy.enabled") return false
return ""
})
const context = createMockContext()
void initializeNetworkProxy(context, mockOutputChannel)
expect(isProxyEnabled()).toBe(false)
})
it("should return true when proxy is enabled in debug mode", () => {
mockConfig.get.mockImplementation((key: string) => {
if (key === "debugProxy.enabled") return true
if (key === "debugProxy.serverUrl") return "http://localhost:8080"
return ""
})
// Proxy is only applied in debug mode.
const context = createMockContext(vscode.ExtensionMode.Development)
void initializeNetworkProxy(context, mockOutputChannel)
expect(isProxyEnabled()).toBe(true)
})
})
describe("isDebugMode", () => {
it("should return false in production mode", () => {
const context = createMockContext(vscode.ExtensionMode.Production)
void initializeNetworkProxy(context, mockOutputChannel)
expect(isDebugMode()).toBe(false)
})
it("should return true in development mode", () => {
const context = createMockContext(vscode.ExtensionMode.Development)
void initializeNetworkProxy(context, mockOutputChannel)
expect(isDebugMode()).toBe(true)
})
// Note: This test is skipped because module state persists across tests.
// In a real scenario, isDebugMode() returns false before any initialization.
// The actual behavior is verified in integration testing.
it.skip("should return false before initialization", () => {
// This would require full module isolation which isn't practical here
expect(isDebugMode()).toBe(false)
})
})
describe("security", () => {
it("should not disable TLS verification unless tlsInsecure is enabled", () => {
mockConfig.get.mockImplementation((key: string) => {
if (key === "debugProxy.enabled") return true
if (key === "debugProxy.serverUrl") return "http://localhost:8080"
if (key === "debugProxy.tlsInsecure") return false
return ""
})
const context = createMockContext(vscode.ExtensionMode.Development)
void initializeNetworkProxy(context, mockOutputChannel)
expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined()
})
})
})

364
src/utils/networkProxy.ts Normal file
View file

@ -0,0 +1,364 @@
/**
* Network Proxy Configuration Module
*
* Provides proxy configuration for all outbound HTTP/HTTPS requests from the Roo Code extension.
* When running in debug mode (F5), a proxy can be enabled for outbound traffic.
* Optionally, TLS certificate verification can be disabled (debug only) to allow
* MITM proxy inspection.
*
* Uses global-agent to globally route all HTTP/HTTPS traffic through the proxy,
* which works with axios, fetch, and most SDKs that use native Node.js http/https.
*/
import * as vscode from "vscode"
import { Package } from "../shared/package"
/**
* Proxy configuration state
*/
export interface ProxyConfig {
/** Whether the debug proxy is enabled */
enabled: boolean
/** The proxy server URL (e.g., http://127.0.0.1:8888) */
serverUrl: string
/** Accept self-signed/insecure TLS certificates from the proxy (required for MITM) */
tlsInsecure: boolean
/** Whether running in debug/development mode */
isDebugMode: boolean
}
let extensionContext: vscode.ExtensionContext | null = null
let proxyInitialized = false
let undiciProxyInitialized = false
let fetchPatched = false
let originalFetch: typeof fetch | undefined
let outputChannel: vscode.OutputChannel | null = null
let loggingEnabled = false
let consoleLoggingEnabled = false
let tlsVerificationOverridden = false
let originalNodeTlsRejectUnauthorized: string | undefined
function redactProxyUrl(proxyUrl: string | undefined): string {
if (!proxyUrl) {
return "(not set)"
}
try {
const url = new URL(proxyUrl)
url.username = ""
url.password = ""
return url.toString()
} catch {
// Fallback for invalid URLs: redact basic auth if present.
return proxyUrl.replace(/\/\/[^@/]+@/g, "//REDACTED@")
}
}
function restoreGlobalFetchPatch(): void {
if (!fetchPatched) {
return
}
if (originalFetch) {
globalThis.fetch = originalFetch
}
fetchPatched = false
originalFetch = undefined
}
function restoreTlsVerificationOverride(): void {
if (!tlsVerificationOverridden) {
return
}
if (typeof originalNodeTlsRejectUnauthorized === "string") {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalNodeTlsRejectUnauthorized
} else {
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED
}
tlsVerificationOverridden = false
originalNodeTlsRejectUnauthorized = undefined
}
function applyTlsVerificationOverride(config: ProxyConfig): void {
// Only relevant in debug mode with an active proxy.
if (!config.isDebugMode || !config.enabled) {
restoreTlsVerificationOverride()
return
}
if (!config.tlsInsecure) {
restoreTlsVerificationOverride()
return
}
if (!tlsVerificationOverridden) {
originalNodeTlsRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED
}
// CodeQL: debug-only opt-in for MITM debugging.
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" // lgtm[js/disabling-certificate-validation]
tlsVerificationOverridden = true
}
/**
* Initialize the network proxy module with the extension context.
* Must be called early in extension activation before any network requests.
*
* @param context The VS Code extension context
* @param channel Optional output channel for logging
*/
export async function initializeNetworkProxy(
context: vscode.ExtensionContext,
channel?: vscode.OutputChannel,
): Promise<void> {
extensionContext = context
// extensionMode is immutable for the process lifetime - exit early if not in debug mode.
// This avoids any overhead (listeners, logging, etc.) in production.
const isDebugMode = context.extensionMode === vscode.ExtensionMode.Development
if (!isDebugMode) {
return
}
outputChannel = channel ?? null
loggingEnabled = true
consoleLoggingEnabled = !outputChannel
const config = getProxyConfig()
log(`Initializing network proxy module...`)
log(
`Proxy config: enabled=${config.enabled}, serverUrl=${redactProxyUrl(config.serverUrl)}, tlsInsecure=${config.tlsInsecure}`,
)
// Listen for configuration changes to allow toggling proxy during a debug session.
// Guard for test environments where onDidChangeConfiguration may not be mocked.
if (typeof vscode.workspace.onDidChangeConfiguration === "function") {
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((e) => {
if (
e.affectsConfiguration(`${Package.name}.debugProxy.enabled`) ||
e.affectsConfiguration(`${Package.name}.debugProxy.serverUrl`) ||
e.affectsConfiguration(`${Package.name}.debugProxy.tlsInsecure`)
) {
const newConfig = getProxyConfig()
if (newConfig.enabled) {
applyTlsVerificationOverride(newConfig)
configureGlobalProxy(newConfig)
configureUndiciProxy(newConfig)
} else {
// Proxy disabled - but we can't easily un-bootstrap global-agent or reset undici dispatcher safely.
// We *can* restore any global fetch patch immediately.
restoreGlobalFetchPatch()
restoreTlsVerificationOverride()
log("Debug proxy disabled. Restart VS Code to fully disable proxy routing.")
}
}
}),
)
}
// Ensure we restore any overrides when the extension unloads.
context.subscriptions.push({
dispose: () => {
restoreGlobalFetchPatch()
restoreTlsVerificationOverride()
},
})
if (config.enabled) {
applyTlsVerificationOverride(config)
await configureGlobalProxy(config)
await configureUndiciProxy(config)
} else {
log(`Debug proxy not enabled.`)
}
}
/**
* Get the current proxy configuration based on VS Code settings and extension mode.
*/
export function getProxyConfig(): ProxyConfig {
const defaultServerUrl = "http://127.0.0.1:8888"
if (!extensionContext) {
// Fallback if called before initialization
return {
enabled: false,
serverUrl: defaultServerUrl,
tlsInsecure: false,
isDebugMode: false,
}
}
const config = vscode.workspace.getConfiguration(Package.name)
const enabled = Boolean(config.get<unknown>("debugProxy.enabled"))
const rawServerUrl = config.get<unknown>("debugProxy.serverUrl")
const serverUrl = typeof rawServerUrl === "string" && rawServerUrl.trim() ? rawServerUrl.trim() : defaultServerUrl
const tlsInsecure = Boolean(config.get<unknown>("debugProxy.tlsInsecure"))
// Debug mode only.
const isDebugMode = extensionContext.extensionMode === vscode.ExtensionMode.Development
return {
enabled,
serverUrl,
tlsInsecure,
isDebugMode,
}
}
/**
* Configure global-agent to route all HTTP/HTTPS traffic through the proxy.
*/
async function configureGlobalProxy(config: ProxyConfig): Promise<void> {
if (proxyInitialized) {
// global-agent can only be bootstrapped once
// Update environment variables for any new connections
log(`Proxy already initialized, updating env vars only`)
updateProxyEnvVars(config)
return
}
// Set up environment variables before bootstrapping
log(`Setting proxy environment variables before bootstrap (values redacted)...`)
updateProxyEnvVars(config)
let bootstrap: (() => void) | undefined
try {
const mod = (await import("global-agent")) as typeof import("global-agent")
bootstrap = mod.bootstrap
} catch (error) {
log(
`Failed to load global-agent (proxy support is only available in debug/dev builds): ${error instanceof Error ? error.message : String(error)}`,
)
return
}
// Bootstrap global-agent to intercept all HTTP/HTTPS requests
log(`Calling global-agent bootstrap()...`)
try {
bootstrap()
proxyInitialized = true
log(`global-agent bootstrap() completed successfully`)
} catch (error) {
log(`global-agent bootstrap() FAILED: ${error instanceof Error ? error.message : String(error)}`)
return
}
log(`Network proxy configured: ${redactProxyUrl(config.serverUrl)}`)
}
/**
* Configure undici's global dispatcher so Node's built-in `fetch()` and any undici-based
* clients route through the proxy.
*/
async function configureUndiciProxy(config: ProxyConfig): Promise<void> {
if (!config.enabled || !config.serverUrl) {
return
}
if (undiciProxyInitialized) {
log(`undici global dispatcher already configured; restart VS Code to change proxy safely`)
return
}
try {
const {
ProxyAgent,
setGlobalDispatcher,
fetch: undiciFetch,
} = (await import("undici")) as typeof import("undici")
const proxyAgent = new ProxyAgent({
uri: config.serverUrl,
// If the user enabled TLS insecure mode (debug only), apply it to undici.
requestTls: config.tlsInsecure
? ({ rejectUnauthorized: false } satisfies import("tls").ConnectionOptions) // lgtm[js/disabling-certificate-validation]
: undefined,
proxyTls: config.tlsInsecure
? ({ rejectUnauthorized: false } satisfies import("tls").ConnectionOptions) // lgtm[js/disabling-certificate-validation]
: undefined,
})
setGlobalDispatcher(proxyAgent)
undiciProxyInitialized = true
log(`undici global dispatcher configured for proxy: ${redactProxyUrl(config.serverUrl)}`)
// Node's built-in `fetch()` (Node 18+) is powered by an internal undici copy.
// Setting a dispatcher on our `undici` dependency does NOT affect that internal fetch.
// To ensure Roo Code's `fetch()` calls are proxied, patch global fetch in debug mode.
// This patch is scoped to the extension lifecycle (restored on deactivate) and can be restored
// immediately if the proxy is disabled.
if (!fetchPatched) {
if (typeof globalThis.fetch === "function") {
originalFetch = globalThis.fetch
}
globalThis.fetch = undiciFetch as unknown as typeof fetch
fetchPatched = true
log(`globalThis.fetch patched to undici.fetch (debug proxy mode)`)
if (extensionContext) {
extensionContext.subscriptions.push({
dispose: () => restoreGlobalFetchPatch(),
})
}
}
} catch (error) {
log(`Failed to configure undici proxy dispatcher: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Update environment variables for proxy configuration.
* global-agent reads from GLOBAL_AGENT_* environment variables.
*/
function updateProxyEnvVars(config: ProxyConfig): void {
if (config.serverUrl) {
// global-agent uses these environment variables
process.env.GLOBAL_AGENT_HTTP_PROXY = config.serverUrl
process.env.GLOBAL_AGENT_HTTPS_PROXY = config.serverUrl
process.env.GLOBAL_AGENT_NO_PROXY = "" // Proxy all requests
}
}
/**
* Check if a proxy is currently configured and active.
*/
export function isProxyEnabled(): boolean {
const config = getProxyConfig()
// Active proxy is only applied in debug mode.
return config.enabled && config.isDebugMode
}
/**
* Check if we're running in debug mode.
*/
export function isDebugMode(): boolean {
if (!extensionContext) {
return false
}
return extensionContext.extensionMode === vscode.ExtensionMode.Development
}
/**
* Log a message to the output channel if available.
*/
function log(message: string): void {
if (!loggingEnabled) {
return
}
const logMessage = `[NetworkProxy] ${message}`
if (outputChannel) {
outputChannel.appendLine(logMessage)
}
if (consoleLoggingEnabled) {
console.log(logMessage)
}
}

View file

@ -44,24 +44,9 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {
<div className="mb-4">
<p className="mb-3">{t("chat:announcement.release.heading")}</p>
<ul className="list-disc list-inside text-sm space-y-1.5">
<li>
<Trans
i18nKey="chat:announcement.release.skills"
components={{
skillsLink: <BlogLink href="https://agentskills.io" />,
}}
/>
</li>
<li>
<Trans
i18nKey="chat:announcement.release.nativeToolCalling"
components={{
blogLink: (
<BlogLink href="https://blog.roocode.com/p/sorry-we-didnt-listen-sooner-native" />
),
}}
/>
</li>
<li>{t("chat:announcement.release.stickyProfiles")}</li>
<li>{t("chat:announcement.release.imageMentions")}</li>
<li>{t("chat:announcement.release.brrrMode")}</li>
</ul>
</div>
@ -139,15 +124,4 @@ const CareersLink = ({ children }: { children?: ReactNode }) => (
</VSCodeLink>
)
const BlogLink = ({ href, children }: { href: string; children?: ReactNode }) => (
<VSCodeLink
href={href}
onClick={(e) => {
e.preventDefault()
vscode.postMessage({ type: "openExternal", url: href })
}}>
{children}
</VSCodeLink>
)
export default memo(Announcement)

View file

@ -135,14 +135,14 @@ export const FollowUpSuggest = ({
</p>
)}
{suggestion.mode && (
<div className="absolute bottom-0 right-0 text-[10px] bg-vscode-badge-background text-vscode-badge-foreground px-1 py-0.5 border border-vscode-badge-background flex items-center gap-0.5">
<div className="absolute bottom-0 right-0 text-[10px] text-vscode-badge-foreground pl-1 pr-2.5 pt-0.5 pb-1.5 flex items-center gap-0.5 bg-transparent rounded-xl">
<span className="codicon codicon-arrow-right" style={{ fontSize: "8px" }} />
{suggestion.mode}
</div>
)}
<StandardTooltip content={t("chat:followUpSuggest.copyToInput")}>
<div
className="absolute cursor-pointer top-1.5 right-3 opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute cursor-pointer top-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity bg-vscode-input-background px-0.5 rounded"
onClick={(e) => {
e.stopPropagation()
// Cancel the auto-approve timer when edit button is clicked

View file

@ -279,7 +279,7 @@ export const OpenAICompatible = ({
}}
modelInfo={{
...(apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults),
supportsReasoningEffort: true,
supportsReasoningEffort: ["low", "medium", "high", "xhigh"],
}}
/>
)}

View file

@ -5,9 +5,13 @@ const RooHero = () => {
const w = window as any
return w.IMAGES_BASE_URI || ""
})
const [isHovered, setIsHovered] = useState(false)
return (
<div className="mb-4 relative forced-color-adjust-none group flex flex-col items-center w-30 pt-4 overflow-clip">
<div
className="mb-4 relative forced-color-adjust-none group flex flex-col items-center w-30 pt-4 overflow-clip"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
<div
style={{
backgroundColor: "var(--vscode-foreground)",
@ -17,8 +21,9 @@ const RooHero = () => {
maskImage: `url('${imagesBaseUri}/roo-logo.svg')`,
maskRepeat: "no-repeat",
maskSize: "contain",
animation: isHovered ? "smooth-bounce 1s ease-in-out infinite" : "none",
}}
className="z-5 mr-auto group-hover:animate-bounce translate-y-0 transition-transform duration-500">
className="z-5 mr-auto translate-y-0 transition-transform duration-500">
<img src={imagesBaseUri + "/roo-logo.svg"} alt="Roo logo" className="h-8 opacity-0" />
</div>
<div

View file

@ -292,7 +292,7 @@
"triggerLabel_zero": "0 aprovacions automàtiques",
"triggerLabel_one": "1 aprovació automàtica",
"triggerLabel_other": "{{count}} aprovacions automàtiques",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Pensant",
@ -329,8 +329,9 @@
},
"release": {
"heading": "Què hi ha de nou:",
"skills": "Roo ara suporta <skillsLink>Agent Skills</skillsLink> - paquets reutilitzables de prompts, ferramentes i recursos per estendre les capacitats de Roo.",
"nativeToolCalling": "L'invocació nativa d'eines és ara obligatòria per a totes les noves tasques. <blogLink>Més informació</blogLink> sobre la motivació i què fer si trobes problemes."
"stickyProfiles": "Perfils de proveïdor persistents - Les tasques ara recorden la seva configuració API, per tant, canviar de perfil a mitja sessió no afecta les tasques en execució.",
"imageMentions": "Mencions de fitxers d'imatge - Fes referència a imatges directament al xat usant mencions @ per incloure-les al teu context.",
"brrrMode": "YOLO ara és BRRR - Aprovar-ho tot automàticament s'ha reanomenat de \"YOLO\" a \"BRRR\". Òbviament."
},
"cloudAgents": {
"heading": "Novetats al núvol:",

View file

@ -16,7 +16,7 @@
"greeting": "Benvingut a Roo Code!",
"introduction": "Amb una gamma de Modes integrats i ampliables, Roo Code et permet planificar, arquitectar, codificar, depurar i augmentar la teva productivitat com mai abans.",
"accountMention": "Per començar, crea el teu compte Roo Code Cloud. Obtén models potents, control web, analítica, suport i molt més.",
"getStarted": "Comença",
"getStarted": "Crea compte Roo",
"noAccount": "o utilitza sense compte"
},
"providerSignup": {

View file

@ -292,7 +292,7 @@
"triggerLabel_zero": "0 automatisch genehmigt",
"triggerLabel_one": "1 automatisch genehmigt",
"triggerLabel_other": "{{count}} automatisch genehmigt",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Denke nach",
@ -329,8 +329,9 @@
},
"release": {
"heading": "Was ist neu:",
"skills": "Roo unterstützt jetzt <skillsLink>Agent Skills</skillsLink> - wiederverwendbare Pakete aus Prompts, Tools und Ressourcen, um Roos Funktionen zu erweitern.",
"nativeToolCalling": "Native Tool-Aufrufe sind jetzt für alle neuen Aufgaben erforderlich. <blogLink>Mehr erfahren</blogLink> über die Motivation und was zu tun ist, wenn du Probleme hast."
"stickyProfiles": "Sticky Provider-Profile - Aufgaben merken sich jetzt ihre API-Konfiguration, daher wirkt sich ein Profilwechsel während einer Sitzung nicht auf laufende Aufgaben aus.",
"imageMentions": "Bilddatei @-Mentions - Referenziere Bilder direkt im Chat mit @ Mentions, um sie in deinen Kontext einzubeziehen.",
"brrrMode": "YOLO ist jetzt BRRR - Die automatische Genehmigung von allem wurde von \"YOLO\" in \"BRRR\" umbenannt. Natürlich."
},
"cloudAgents": {
"heading": "Neu in der Cloud:",

View file

@ -16,7 +16,7 @@
"greeting": "Willkommen bei Roo Code!",
"introduction": "Mit einer Reihe von integrierten und erweiterbaren Modi ermöglicht dir Roo Code, zu planen, zu architektieren, zu coden, zu debuggen und deine Produktivität wie nie zuvor zu steigern.",
"accountMention": "Um zu beginnen, erstelle dein Roo Code Cloud Konto. Erhalte leistungsstarke Modelle, Webkontrolle, Analysen, Support und mehr.",
"getStarted": "Loslegen",
"getStarted": "Roo-Konto erstellen",
"noAccount": "oder ohne Konto verwenden"
},
"providerSignup": {

View file

@ -322,7 +322,7 @@
"triggerLabel_zero": "0 auto-approve",
"triggerLabel_one": "1 auto-approved",
"triggerLabel_other": "{{count}} auto-approved",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"announcement": {
"title": "Roo Code {{version}} Released",
@ -336,8 +336,9 @@
},
"release": {
"heading": "What's New:",
"skills": "Roo now supports <skillsLink>Agent Skills</skillsLink> - reusable packages of prompts, tools, and resources to extend Roo's capabilities.",
"nativeToolCalling": "Native tool calling is now required for all new tasks. <blogLink>Read more</blogLink> about the motivation and what to do if you're encountering issues."
"stickyProfiles": "Sticky provider profiles - Tasks now remember their API configuration, so switching profiles mid-session doesn't affect running tasks.",
"imageMentions": "Image file @mentions - Reference images directly in chat using @ mentions to include them in your context.",
"brrrMode": "YOLO is now BRRR - Auto-approving everything has been renamed from \"YOLO\" to \"BRRR\". Obviously."
},
"cloudAgents": {
"heading": "New in the Cloud:",

View file

@ -16,7 +16,7 @@
"greeting": "Welcome to Roo Code!",
"introduction": "With a range of built-in and extensible Modes, Roo Code lets you plan, architect, code, debug and boost your productivity like never before.",
"accountMention": "To get started, create your Roo Code Cloud account. Get powerful models, web control, analytics, support and more.",
"getStarted": "Get started",
"getStarted": "Create Roo Account",
"noAccount": "or use without an account"
},
"providerSignup": {

View file

@ -292,7 +292,7 @@
"triggerLabel_zero": "0 aprobaciones automáticas",
"triggerLabel_one": "1 aprobación automática",
"triggerLabel_other": "{{count}} aprobaciones automáticas",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Pensando",
@ -329,8 +329,9 @@
},
"release": {
"heading": "Qué hay de nuevo:",
"skills": "Roo ahora soporta <skillsLink>Agent Skills</skillsLink> - paquetes reutilizables de prompts, herramientas y recursos para extender las capacidades de Roo.",
"nativeToolCalling": "Las llamadas nativas de herramientas ahora son necesarias para todas las nuevas tareas. <blogLink>Lee más</blogLink> sobre la motivación y qué hacer si encuentras problemas."
"stickyProfiles": "Perfiles de proveedor persistentes - Las tareas ahora recuerdan su configuración de API, por lo que cambiar de perfil a mitad de sesión no afecta las tareas en ejecución.",
"imageMentions": "Menciones de archivos de imagen - Haz referencia a imágenes directamente en el chat usando menciones @ para incluirlas en tu contexto.",
"brrrMode": "YOLO ahora es BRRR - Aprobar todo automáticamente ha sido renombrado de \"YOLO\" a \"BRRR\". Obviamente."
},
"cloudAgents": {
"heading": "Novedades en la Nube:",

View file

@ -16,7 +16,7 @@
"greeting": "¡Bienvenido a Roo Code!",
"introduction": "Con una variedad de Modos integrados y extensibles, Roo Code te permite planificar, arquitectar, codificar, depurar y aumentar tu productividad como nunca antes.",
"accountMention": "Para comenzar, crea tu cuenta de Roo Code Cloud. Obtén modelos potentes, control web, análisis, soporte y más.",
"getStarted": "Comenzar",
"getStarted": "Crear cuenta de Roo",
"noAccount": "o usar sin cuenta"
},
"providerSignup": {

View file

@ -292,7 +292,7 @@
"triggerLabel_zero": "0 approuvé automatiquement",
"triggerLabel_one": "1 approuvé automatiquement",
"triggerLabel_other": "{{count}} approuvés automatiquement",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Réflexion",
@ -329,8 +329,9 @@
},
"release": {
"heading": "Quoi de neuf :",
"skills": "Roo supporte maintenant les <skillsLink>Agent Skills</skillsLink> - des packages réutilisables de prompts, d'outils et de ressources pour étendre les capacités de Roo.",
"nativeToolCalling": "L'appel natif des outils est maintenant obligatoire pour toutes les nouvelles tâches. <blogLink>En savoir plus</blogLink> sur la motivation et ce qu'il faut faire si tu rencontres des problèmes."
"stickyProfiles": "Profils de fournisseur persistants - Les tâches se souviennent maintenant de leur configuration API, donc changer de profil en cours de session n'affecte pas les tâches en cours d'exécution.",
"imageMentions": "Mentions de fichiers d'image - Référencez les images directement dans le chat en utilisant les mentions @ pour les inclure dans votre contexte.",
"brrrMode": "YOLO est maintenant BRRR - L'approbation automatique de tout a été renommée de \"YOLO\" en \"BRRR\". Évidemment."
},
"cloudAgents": {
"heading": "Nouveautés dans le Cloud :",

View file

@ -16,7 +16,7 @@
"greeting": "Bienvenue sur Roo Code !",
"introduction": "Avec une gamme de Modes intégrés et extensibles, Roo Code te permet de planifier, architecturer, coder, déboguer et augmenter ta productivité comme jamais auparavant.",
"accountMention": "Pour commencer, crée ton compte Roo Code Cloud. Obtiens des modèles puissants, le contrôle web, les analyses, le support et bien plus encore.",
"getStarted": "Commencer",
"getStarted": "Créer un compte Roo",
"noAccount": "ou utiliser sans compte"
},
"providerSignup": {

View file

@ -292,7 +292,7 @@
"triggerLabel_zero": "0 स्वतः-अनुमोदन",
"triggerLabel_one": "1 स्वतः-अनुमोदित",
"triggerLabel_other": "{{count}} स्वतः-अनुमोदित",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "विचार कर रहा है",
@ -328,9 +328,10 @@
"goToSettingsButton": "सेटिंग्स पर जाएं"
},
"release": {
"heading": "नया:",
"skills": "Roo अब <skillsLink>Agent Skills</skillsLink> का समर्थन करता है - प्रॉम्प्ट, टूल्स और संसाधनों के पुन:उपयोग योग्य पैकेज जो Roo की क्षमताओं को बढ़ाते हैं।",
"nativeToolCalling": "सभी नए कार्यों के लिए अब मूल टूल कॉलिंग आवश्यक है। <blogLink>अधिक जानें</blogLink> प्रेरणा और समस्याओं का सामना करने पर क्या करना है इसके बारे में।"
"heading": "नया क्या है:",
"stickyProfiles": "स्टिकी प्रोवाइडर प्रोफाइलें - कार्य अब अपने API कॉन्फ़िगरेशन को याद रखते हैं, इसलिए सत्र के बीच प्रोफाइल स्विच करने से चलने वाले कार्य प्रभावित नहीं होते हैं।",
"imageMentions": "इमेज फ़ाइल @mentions - चैट में सीधे @ mentions का उपयोग करके छवियों का संदर्भ दें और उन्हें अपने संदर्भ में शामिल करें।",
"brrrMode": "YOLO अब BRRR है - सब कुछ स्वचालित-अनुमोदन करने का नाम \"YOLO\" से \"BRRR\" में बदल दिया गया है। स्पष्ट है।"
},
"cloudAgents": {
"heading": "क्लाउड में नया:",

View file

@ -16,7 +16,7 @@
"greeting": "Roo Code में आपका स्वागत है!",
"introduction": "अंतर्निहित और विस्तारित मोड्स की एक श्रृंखला के साथ, Roo Code आपको पहले कभी न देखे गए तरीके से योजना बनाने, आर्किटेक्ट करने, कोड करने, डीबग करने और अपनी उत्पादकता बढ़ाने की अनुमति देता है।",
"accountMention": "शुरुआत करने के लिए, अपना Roo Code Cloud खाता बनाएं। शक्तिशाली मॉडल, वेब नियंत्रण, विश्लेषण, समर्थन और अधिक प्राप्त करें।",
"getStarted": "शुरुआत करें",
"getStarted": "Roo खाता बनाएं",
"noAccount": "या बिना खाते के उपयोग करें"
},
"providerSignup": {

View file

@ -328,7 +328,7 @@
"triggerLabel_zero": "0 disetujui otomatis",
"triggerLabel_one": "1 disetujui otomatis",
"triggerLabel_other": "{{count}} disetujui otomatis",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"announcement": {
"title": "Roo Code {{version}} Dirilis",
@ -342,8 +342,9 @@
},
"release": {
"heading": "Yang Baru:",
"skills": "Roo sekarang mendukung <skillsLink>Agent Skills</skillsLink> - paket yang dapat digunakan kembali dari prompt, alat, dan sumber daya untuk memperluas kemampuan Roo.",
"nativeToolCalling": "Pemanggilan alat native sekarang diperlukan untuk semua tugas baru. <blogLink>Pelajari lebih lanjut</blogLink> tentang motivasi dan apa yang harus dilakukan jika mengalami masalah."
"stickyProfiles": "Profil Penyedia Lengket - Tugas sekarang mengingat konfigurasi API mereka, jadi beralih profil di tengah sesi tidak mempengaruhi tugas yang sedang berjalan.",
"imageMentions": "Mention File Gambar - Referensikan gambar secara langsung dalam obrolan menggunakan mention @ untuk memasukkannya dalam konteks Anda.",
"brrrMode": "YOLO sekarang BRRR - Persetujuan otomatis untuk semuanya telah diubah namanya dari \"YOLO\" menjadi \"BRRR\". Jelas."
},
"cloudAgents": {
"heading": "Baru di Cloud:",

View file

@ -16,7 +16,7 @@
"greeting": "Selamat datang di Roo Code!",
"introduction": "Dengan berbagai Mode bawaan dan dapat diperluas, Roo Code memungkinkan Anda merencanakan, merancang, coding, debug, dan meningkatkan produktivitas seperti yang belum pernah terjadi sebelumnya.",
"accountMention": "Untuk memulai, buat akun Roo Code Cloud Anda. Dapatkan model yang kuat, kontrol web, analitik, dukungan, dan banyak lagi.",
"getStarted": "Memulai",
"getStarted": "Buat Akun Roo",
"noAccount": "atau gunakan tanpa akun"
},
"providerSignup": {

View file

@ -292,7 +292,7 @@
"triggerLabel_zero": "0 approvati automaticamente",
"triggerLabel_one": "1 approvato automaticamente",
"triggerLabel_other": "{{count}} approvati automaticamente",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Sto pensando",
@ -329,8 +329,9 @@
},
"release": {
"heading": "Novità:",
"skills": "Roo ora supporta <skillsLink>Agent Skills</skillsLink> - pacchetti riutilizzabili di prompt, strumenti e risorse per estendere le capacità di Roo.",
"nativeToolCalling": "La chiamata nativa degli strumenti è ora obbligatoria per tutte le nuove attività. <blogLink>Scopri di più</blogLink> sulla motivazione e cosa fare se riscontri problemi."
"stickyProfiles": "Profili provider persistenti - Le attività ora ricordano la loro configurazione API, quindi cambiare profilo a metà sessione non influisce sulle attività in esecuzione.",
"imageMentions": "Menzioni di file immagine - Fai riferimento alle immagini direttamente nella chat usando le menzioni @ per includerle nel tuo contesto.",
"brrrMode": "YOLO ora è BRRR - L'approvazione automatica di tutto è stata rinominata da \"YOLO\" a \"BRRR\". Ovviamente."
},
"cloudAgents": {
"heading": "Novità nel Cloud:",

View file

@ -16,7 +16,7 @@
"greeting": "Benvenuto in Roo Code!",
"introduction": "Con una gamma di Modalità integrate ed estensibili, Roo Code ti permette di pianificare, architettare, codificare, debuggare e aumentare la tua produttività come mai prima d'ora.",
"accountMention": "Per iniziare, crea il tuo account Roo Code Cloud. Ottieni modelli potenti, controllo web, analitiche, supporto e altro ancora.",
"getStarted": "Inizia",
"getStarted": "Crea account Roo",
"noAccount": "o usa senza account"
},
"providerSignup": {

View file

@ -292,7 +292,7 @@
"triggerLabel_zero": "0個の自動承認",
"triggerLabel_one": "1個の自動承認済み",
"triggerLabel_other": "{{count}}個の自動承認済み",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "考え中",
@ -329,8 +329,9 @@
},
"release": {
"heading": "新機能:",
"skills": "Rooが<skillsLink>Agent Skills</skillsLink>に対応しました - Rooの機能を拡張するためのプロンプト、ツール、リソースの再利用可能なパッケージです。",
"nativeToolCalling": "すべての新しいタスクではネイティブツール呼び出しが必須になりました。<blogLink>詳しく知る</blogLink>理由と問題が発生した場合の対応方法について。"
"stickyProfiles": "スティッキープロバイダープロファイル - タスクはAPI設定を記憶するようになったため、セッション途中にプロファイルを切り替えても実行中のタスクに影響しません。",
"imageMentions": "画像ファイル@メンション - @メンションを使用してチャット内で画像を直接参照し、コンテキストに含めることができます。",
"brrrMode": "YOLOがBRRRに - すべてを自動承認する機能が「YOLO」から「BRRR」に名前変更されました。当然です。"
},
"cloudAgents": {
"heading": "クラウドの新機能:",

View file

@ -16,7 +16,7 @@
"greeting": "Roo Codeへようこそ!",
"introduction": "組み込みおよび拡張可能なモードを備えたRoo Codeは、計画、アーキテクチャ設計、コーディング、デバッグ、そして今までにない生産性向上を可能にします。",
"accountMention": "始めるには、Roo Code Cloudアカウントを作成してください。強力なモデル、ウェブコントロール、分析、サポート、その他をご利用いただけます。",
"getStarted": "始める",
"getStarted": "Rooアカウントを作成",
"noAccount": "またはアカウントなしで使用"
},
"providerSignup": {

View file

@ -292,7 +292,7 @@
"triggerLabel_zero": "0개 자동 승인됨",
"triggerLabel_one": "1개 자동 승인됨",
"triggerLabel_other": "{{count}}개 자동 승인됨",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "생각 중",
@ -329,8 +329,9 @@
},
"release": {
"heading": "새로운 기능:",
"skills": "Roo는 이제 <skillsLink>Agent Skills</skillsLink>를 지원합니다 - Roo의 기능을 확장하기 위한 프롬프트, 도구 및 리소스의 재사용 가능한 패키지입니다.",
"nativeToolCalling": "모든 새 작업에 이제 네이티브 도구 호출이 필요합니다. <blogLink>자세히 알아보기</blogLink> 동기 및 문제가 발생한 경우 수행할 작업에 대해."
"stickyProfiles": "스티커 제공자 프로필 - 작업이 이제 API 구성을 기억하므로 세션 중에 프로필을 전환해도 실행 중인 작업에 영향을 주지 않습니다.",
"imageMentions": "이미지 파일 @mentions - @mentions를 사용하여 채팅에서 직접 이미지를 참조하여 컨텍스트에 포함시킵니다.",
"brrrMode": "YOLO가 BRRR로 변경됨 - 모든 것을 자동 승인하는 기능의 이름이 \"YOLO\"에서 \"BRRR\"로 변경되었습니다. 당연하죠."
},
"cloudAgents": {
"heading": "클라우드의 새로운 기능:",

View file

@ -16,7 +16,7 @@
"greeting": "Roo Code에 오신 것을 환영합니다!",
"introduction": "내장 및 확장 가능한 다양한 모드를 통해 Roo Code는 계획, 아키텍처 설계, 코딩, 디버깅 및 전례 없는 생산성 향상을 가능하게 합니다.",
"accountMention": "시작하려면 Roo Code Cloud 계정을 만드세요. 강력한 모델, 웹 제어, 분석, 지원 등을 얻으세요.",
"getStarted": "시작하기",
"getStarted": "Roo 계정 만들기",
"noAccount": "또는 계정 없이 사용하기"
},
"providerSignup": {

View file

@ -292,7 +292,7 @@
"triggerLabel_zero": "0 automatisch goedgekeurd",
"triggerLabel_one": "1 automatisch goedgekeurd",
"triggerLabel_other": "{{count}} automatisch goedgekeurd",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"announcement": {
"title": "Roo Code {{version}} uitgebracht",
@ -306,8 +306,9 @@
},
"release": {
"heading": "Wat is er nieuw:",
"skills": "Roo ondersteunt nu <skillsLink>Agent Skills</skillsLink> - herbruikbare pakketten van prompts, tools en resources om de mogelijkheden van Roo uit te breiden.",
"nativeToolCalling": "Native tool calling is nu vereist voor alle nieuwe taken. <blogLink>Meer informatie</blogLink> over de motivatie en wat je moet doen als je problemen ondervindt."
"stickyProfiles": "Sticky Provider Profielen - Taken onthouden nu hun API-configuratie, dus het wisselen van profiel in het midden van een sessie beïnvloedt niet de lopende taken.",
"imageMentions": "Bestand @mentions afbeelding - Referentie afbeeldingen rechtstreeks in de chat met behulp van @ mentions om ze in uw context op te nemen.",
"brrrMode": "YOLO is nu BRRR - Alles automatisch goedkeuren is hernoemd van \"YOLO\" naar \"BRRR\". Uiteraard."
},
"cloudAgents": {
"heading": "Nieuw in de Cloud:",

View file

@ -16,7 +16,7 @@
"greeting": "Welkom bij Roo Code!",
"introduction": "Met een reeks ingebouwde en uitbreidbare Modi laat Roo Code je plannen, ontwerpen, coderen, debuggen en je productiviteit verhogen als nooit tevoren.",
"accountMention": "Maak je Roo Code Cloud-account aan om te beginnen. Krijg krachtige modellen, webcontrole, analyses, ondersteuning en meer.",
"getStarted": "Aan de slag",
"getStarted": "Roo-account aanmaken",
"noAccount": "of gebruik zonder account"
},
"providerSignup": {

View file

@ -292,7 +292,7 @@
"triggerLabel_zero": "0 automatycznie zatwierdzone",
"triggerLabel_one": "1 automatycznie zatwierdzony",
"triggerLabel_other": "{{count}} automatycznie zatwierdzonych",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Myślenie",
@ -329,8 +329,9 @@
},
"release": {
"heading": "Co nowego:",
"skills": "Roo obsługuje teraz <skillsLink>Agent Skills</skillsLink> - pakiety narzędzi, podpowiedzi i zasobów do rozszerzenia możliwości Roo.",
"nativeToolCalling": "Natywne wywoływanie narzędzi jest teraz wymagane dla wszystkich nowych zadań. <blogLink>Dowiedz się więcej</blogLink> o motywacji i co robić, jeśli napotkasz problemy."
"stickyProfiles": "Lepkie profile dostawcy - Zadania teraz pamiętają ich konfigurację API, więc przełączanie profili w trakcie sesji nie wpływa na uruchomione zadania.",
"imageMentions": "Wzmianki o plikach obrazu @mentions - Odnieś się bezpośrednio do obrazów w czacie, korzystając z @mentions, aby uwzględnić je w swoim kontekście.",
"brrrMode": "YOLO to teraz BRRR - Automatyczne zatwierdzanie wszystkiego zostało przemianowane z \"YOLO\" na \"BRRR\". Oczywiście."
},
"cloudAgents": {
"heading": "Nowości w chmurze:",

View file

@ -16,7 +16,7 @@
"greeting": "Witaj w Roo Code!",
"introduction": "Dzięki szerokiej gamie wbudowanych i rozszerzalnych Trybów, Roo Code pozwala planować, projektować architekturę, kodować, debugować i zwiększać produktywność jak nigdy dotąd.",
"accountMention": "Aby rozpocząć, utwórz konto Roo Code Cloud. Otrzymaj potężne modele, kontrolę internetową, analizę, wsparcie i wiele więcej.",
"getStarted": "Zacznij",
"getStarted": "Utwórz konto Roo",
"noAccount": "lub używaj bez konta"
},
"providerSignup": {

View file

@ -292,7 +292,7 @@
"triggerLabel_zero": "0 aprovados automaticamente",
"triggerLabel_one": "1 aprovado automaticamente",
"triggerLabel_other": "{{count}} aprovados automaticamente",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Pensando",
@ -329,8 +329,9 @@
},
"release": {
"heading": "Novidades:",
"skills": "Roo agora suporta <skillsLink>Agent Skills</skillsLink> - pacotes reutilizáveis de prompts, ferramentas e recursos para estender os recursos do Roo.",
"nativeToolCalling": "A chamada nativa de ferramentas agora é necessária para todas as novas tarefas. <blogLink>Saiba mais</blogLink> sobre a motivação e o que fazer se encontrar problemas."
"stickyProfiles": "Perfis de Provedor Adesivos - As tarefas agora se lembram de sua configuração de API, portanto, trocar perfis no meio da sessão não afeta as tarefas em execução.",
"imageMentions": "Menções de arquivos de imagem @ - Faça referência a imagens diretamente no chat usando @mentions para incluí-las no seu contexto.",
"brrrMode": "YOLO agora é BRRR - A aprovação automática de tudo foi renomeada de \"YOLO\" para \"BRRR\". Obviamente."
},
"cloudAgents": {
"heading": "Novidades na Nuvem:",

View file

@ -16,7 +16,7 @@
"greeting": "Bem-vindo ao Roo Code!",
"introduction": "Com uma variedade de Modos integrados e extensíveis, o Roo Code permite que você planeje, arquitete, codifique, depure e aumente sua produtividade como nunca antes.",
"accountMention": "Para começar, crie sua conta Roo Code Cloud. Obtenha modelos poderosos, controle na web, análises, suporte e muito mais.",
"getStarted": "Começar",
"getStarted": "Criar conta Roo",
"noAccount": "ou usar sem conta"
},
"providerSignup": {

View file

@ -293,7 +293,7 @@
"triggerLabel_zero": "0 авто-утвержденных",
"triggerLabel_one": "1 авто-утвержден",
"triggerLabel_other": "{{count}} авто-утвержденных",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"announcement": {
"title": "Выпущен Roo Code {{version}}",
@ -307,8 +307,9 @@
},
"release": {
"heading": "Что нового:",
"skills": "Roo теперь поддерживает <skillsLink>Agent Skills</skillsLink> - переиспользуемые пакеты промптов, инструментов и ресурсов для расширения возможностей Roo.",
"nativeToolCalling": "Нативный вызов инструментов теперь требуется для всех новых задач. <blogLink>Узнайте больше</blogLink> о мотивации и что делать, если вы столкнулись с проблемами."
"stickyProfiles": "Липкие профили поставщика - Задачи теперь запоминают свою конфигурацию API, поэтому переключение профилей в середине сеанса не влияет на запущенные задачи.",
"imageMentions": "Упоминания файлов изображений @mentions - Ссылайтесь на изображения напрямую в чате, используя @mentions, чтобы включить их в свой контекст.",
"brrrMode": "YOLO теперь BRRR - Автоматическое одобрение всего было переименовано с \"YOLO\" на \"BRRR\". Очевидно."
},
"cloudAgents": {
"heading": "Новое в облаке:",

View file

@ -16,7 +16,7 @@
"greeting": "Добро пожаловать в Roo Code!",
"introduction": "С набором встроенных и расширяемых Режимов, Roo Code позволяет вам планировать, проектировать, писать код, отлаживать и повышать продуктивность как никогда раньше.",
"accountMention": "Для начала создайте свой аккаунт Roo Code Cloud. Получите мощные модели, веб-управление, аналитику, поддержку и многое другое.",
"getStarted": "Начать",
"getStarted": "Создать аккаунт Roo",
"noAccount": "или использовать без аккаунта"
},
"providerSignup": {

View file

@ -293,7 +293,7 @@
"triggerLabel_zero": "0 otomatik onaylandı",
"triggerLabel_one": "1 otomatik onaylandı",
"triggerLabel_other": "{{count}} otomatik onaylandı",
"triggerLabelAll": "YOLO"
"triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Düşünüyor",
@ -330,8 +330,9 @@
},
"release": {
"heading": "Yenilikler:",
"skills": "Roo artık <skillsLink>Agent Skills</skillsLink>'i destekliyor - Roo'nun yeteneklerini genişletmek için yeniden kullanılabilir istem, araç ve kaynak paketleri.",
"nativeToolCalling": "Tüm yeni görevler için artık yerel araç çağrısı gereklidir. <blogLink>Daha fazla bilgi edinin</blogLink> motivasyon ve sorunlarla karşılaşırsanız ne yapacağınız hakkında."
"stickyProfiles": "Yapışkan Sağlayıcı Profilleri - Görevler artık API yapılandırmalarını hatırlıyor, bu nedenle oturum ortasında profil değiştirmek çalışan görevleri etkilemez.",
"imageMentions": "Görüntü Dosyası @Mentions - Bağlamınıza dahil etmek için @mentions kullanarak sohbette doğrudan görüntülere başvurun.",
"brrrMode": "YOLO artık BRRR - Her şeyi otomatik onaylama \"YOLO\"dan \"BRRR\" olarak yeniden adlandırıldı. Tabii ki."
},
"cloudAgents": {
"heading": "Cloud'daki yenilikler:",

View file

@ -16,7 +16,7 @@
"greeting": "Roo Code'a Hoş Geldin!",
"introduction": "Yerleşik ve genişletilebilir Modlar yelpazesiyle Roo Code, daha önce hiç olmadığı gibi planlama, mimari tasarım, kodlama, hata ayıklama ve üretkenliğini artırmanı sağlar.",
"accountMention": "Başlamak için Roo Code Cloud hesabını oluştur. Güçlü modeller, web kontrolü, analizler, destek ve daha fazlasını al.",
"getStarted": "Başla",
"getStarted": "Roo Hesabı Oluştur",
"noAccount": "veya hesap olmadan kullan"
},
"providerSignup": {

Some files were not shown because too many files have changed in this diff Show more