mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-06 02:47:56 +00:00
Merge main
This commit is contained in:
commit
9a177cae28
107 changed files with 4414 additions and 248 deletions
|
|
@ -7,5 +7,5 @@
|
|||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
"ignore": ["@roo-code/cli"]
|
||||
}
|
||||
|
|
|
|||
188
.roo/skills/evals-context/SKILL.md
Normal file
188
.roo/skills/evals-context/SKILL.md
Normal 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`
|
||||
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -1,5 +1,34 @@
|
|||
# Roo Code Changelog
|
||||
|
||||
## 3.39.0
|
||||
|
||||

|
||||
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
20
apps/web-evals/src/lib/normalize-create-run.ts
Normal file
20
apps/web-evals/src/lib/normalize-create-run.ts
Normal 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 : [],
|
||||
}
|
||||
}
|
||||
76
apps/web-evals/src/lib/roo-last-model-selection.ts
Normal file
76
apps/web-evals/src/lib/roo-last-model-selection.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
74
pnpm-lock.yaml
generated
|
|
@ -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
BIN
releases/3.39.0-release.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
193
src/core/mentions/__tests__/resolveImageMentions.spec.ts
Normal file
193
src/core/mentions/__tests__/resolveImageMentions.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
145
src/core/mentions/resolveImageMentions.ts
Normal file
145
src/core/mentions/resolveImageMentions.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
66
src/core/prompts/sections/__tests__/system-info.spec.ts
Normal file
66
src/core/prompts/sections/__tests__/system-info.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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$"
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
142
src/core/task/__tests__/Task.sticky-profile-race.spec.ts
Normal file
142
src/core/task/__tests__/Task.sticky-profile-race.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
883
src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts
Normal file
883
src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts
Normal 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"),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
5
src/package.nls.ca.json
generated
5
src/package.nls.ca.json
generated
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.de.json
generated
5
src/package.nls.de.json
generated
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.es.json
generated
5
src/package.nls.es.json
generated
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.fr.json
generated
5
src/package.nls.fr.json
generated
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.hi.json
generated
5
src/package.nls.hi.json
generated
|
|
@ -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 के लिए इस्तेमाल करो।"
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.id.json
generated
5
src/package.nls.id.json
generated
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.it.json
generated
5
src/package.nls.it.json
generated
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.ja.json
generated
5
src/package.nls.ja.json
generated
|
|
@ -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 インスペクションに必須です。** ⚠️ 危険な設定なので、ローカルでのデバッグにだけ使用してください。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.ko.json
generated
5
src/package.nls.ko.json
generated
|
|
@ -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 검사에 필요합니다.** ⚠️ 안전하지 않으므로 로컬 디버깅에만 사용하세요."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.nl.json
generated
5
src/package.nls.nl.json
generated
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.pl.json
generated
5
src/package.nls.pl.json
generated
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.pt-BR.json
generated
5
src/package.nls.pt-BR.json
generated
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.ru.json
generated
5
src/package.nls.ru.json
generated
|
|
@ -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-инспекции.** ⚠️ Небезопасно — используй только для локальной отладки."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.tr.json
generated
5
src/package.nls.tr.json
generated
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.vi.json
generated
5
src/package.nls.vi.json
generated
|
|
@ -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ộ."
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.zh-CN.json
generated
5
src/package.nls.zh-CN.json
generated
|
|
@ -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 检查所必需。** ⚠️ 不安全——只在本地调试时使用。"
|
||||
}
|
||||
|
|
|
|||
5
src/package.nls.zh-TW.json
generated
5
src/package.nls.zh-TW.json
generated
|
|
@ -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
47
src/types/global-agent.d.ts
vendored
Normal 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
|
||||
}
|
||||
308
src/utils/__tests__/networkProxy.spec.ts
Normal file
308
src/utils/__tests__/networkProxy.spec.ts
Normal 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
364
src/utils/networkProxy.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ export const OpenAICompatible = ({
|
|||
}}
|
||||
modelInfo={{
|
||||
...(apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults),
|
||||
supportsReasoningEffort: true,
|
||||
supportsReasoningEffort: ["low", "medium", "high", "xhigh"],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/ca/chat.json
generated
7
webview-ui/src/i18n/locales/ca/chat.json
generated
|
|
@ -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:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/ca/welcome.json
generated
2
webview-ui/src/i18n/locales/ca/welcome.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/de/chat.json
generated
7
webview-ui/src/i18n/locales/de/chat.json
generated
|
|
@ -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:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/de/welcome.json
generated
2
webview-ui/src/i18n/locales/de/welcome.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/es/chat.json
generated
7
webview-ui/src/i18n/locales/es/chat.json
generated
|
|
@ -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:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/es/welcome.json
generated
2
webview-ui/src/i18n/locales/es/welcome.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/fr/chat.json
generated
7
webview-ui/src/i18n/locales/fr/chat.json
generated
|
|
@ -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 :",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/fr/welcome.json
generated
2
webview-ui/src/i18n/locales/fr/welcome.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
9
webview-ui/src/i18n/locales/hi/chat.json
generated
9
webview-ui/src/i18n/locales/hi/chat.json
generated
|
|
@ -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": "क्लाउड में नया:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/hi/welcome.json
generated
2
webview-ui/src/i18n/locales/hi/welcome.json
generated
|
|
@ -16,7 +16,7 @@
|
|||
"greeting": "Roo Code में आपका स्वागत है!",
|
||||
"introduction": "अंतर्निहित और विस्तारित मोड्स की एक श्रृंखला के साथ, Roo Code आपको पहले कभी न देखे गए तरीके से योजना बनाने, आर्किटेक्ट करने, कोड करने, डीबग करने और अपनी उत्पादकता बढ़ाने की अनुमति देता है।",
|
||||
"accountMention": "शुरुआत करने के लिए, अपना Roo Code Cloud खाता बनाएं। शक्तिशाली मॉडल, वेब नियंत्रण, विश्लेषण, समर्थन और अधिक प्राप्त करें।",
|
||||
"getStarted": "शुरुआत करें",
|
||||
"getStarted": "Roo खाता बनाएं",
|
||||
"noAccount": "या बिना खाते के उपयोग करें"
|
||||
},
|
||||
"providerSignup": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/id/chat.json
generated
7
webview-ui/src/i18n/locales/id/chat.json
generated
|
|
@ -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:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/id/welcome.json
generated
2
webview-ui/src/i18n/locales/id/welcome.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/it/chat.json
generated
7
webview-ui/src/i18n/locales/it/chat.json
generated
|
|
@ -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:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/it/welcome.json
generated
2
webview-ui/src/i18n/locales/it/welcome.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/ja/chat.json
generated
7
webview-ui/src/i18n/locales/ja/chat.json
generated
|
|
@ -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": "クラウドの新機能:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/ja/welcome.json
generated
2
webview-ui/src/i18n/locales/ja/welcome.json
generated
|
|
@ -16,7 +16,7 @@
|
|||
"greeting": "Roo Codeへようこそ!",
|
||||
"introduction": "組み込みおよび拡張可能なモードを備えたRoo Codeは、計画、アーキテクチャ設計、コーディング、デバッグ、そして今までにない生産性向上を可能にします。",
|
||||
"accountMention": "始めるには、Roo Code Cloudアカウントを作成してください。強力なモデル、ウェブコントロール、分析、サポート、その他をご利用いただけます。",
|
||||
"getStarted": "始める",
|
||||
"getStarted": "Rooアカウントを作成",
|
||||
"noAccount": "またはアカウントなしで使用"
|
||||
},
|
||||
"providerSignup": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/ko/chat.json
generated
7
webview-ui/src/i18n/locales/ko/chat.json
generated
|
|
@ -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": "클라우드의 새로운 기능:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/ko/welcome.json
generated
2
webview-ui/src/i18n/locales/ko/welcome.json
generated
|
|
@ -16,7 +16,7 @@
|
|||
"greeting": "Roo Code에 오신 것을 환영합니다!",
|
||||
"introduction": "내장 및 확장 가능한 다양한 모드를 통해 Roo Code는 계획, 아키텍처 설계, 코딩, 디버깅 및 전례 없는 생산성 향상을 가능하게 합니다.",
|
||||
"accountMention": "시작하려면 Roo Code Cloud 계정을 만드세요. 강력한 모델, 웹 제어, 분석, 지원 등을 얻으세요.",
|
||||
"getStarted": "시작하기",
|
||||
"getStarted": "Roo 계정 만들기",
|
||||
"noAccount": "또는 계정 없이 사용하기"
|
||||
},
|
||||
"providerSignup": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/nl/chat.json
generated
7
webview-ui/src/i18n/locales/nl/chat.json
generated
|
|
@ -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:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/nl/welcome.json
generated
2
webview-ui/src/i18n/locales/nl/welcome.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/pl/chat.json
generated
7
webview-ui/src/i18n/locales/pl/chat.json
generated
|
|
@ -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:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/pl/welcome.json
generated
2
webview-ui/src/i18n/locales/pl/welcome.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/pt-BR/chat.json
generated
7
webview-ui/src/i18n/locales/pt-BR/chat.json
generated
|
|
@ -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:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/pt-BR/welcome.json
generated
2
webview-ui/src/i18n/locales/pt-BR/welcome.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/ru/chat.json
generated
7
webview-ui/src/i18n/locales/ru/chat.json
generated
|
|
@ -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": "Новое в облаке:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/ru/welcome.json
generated
2
webview-ui/src/i18n/locales/ru/welcome.json
generated
|
|
@ -16,7 +16,7 @@
|
|||
"greeting": "Добро пожаловать в Roo Code!",
|
||||
"introduction": "С набором встроенных и расширяемых Режимов, Roo Code позволяет вам планировать, проектировать, писать код, отлаживать и повышать продуктивность как никогда раньше.",
|
||||
"accountMention": "Для начала создайте свой аккаунт Roo Code Cloud. Получите мощные модели, веб-управление, аналитику, поддержку и многое другое.",
|
||||
"getStarted": "Начать",
|
||||
"getStarted": "Создать аккаунт Roo",
|
||||
"noAccount": "или использовать без аккаунта"
|
||||
},
|
||||
"providerSignup": {
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/tr/chat.json
generated
7
webview-ui/src/i18n/locales/tr/chat.json
generated
|
|
@ -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:",
|
||||
|
|
|
|||
2
webview-ui/src/i18n/locales/tr/welcome.json
generated
2
webview-ui/src/i18n/locales/tr/welcome.json
generated
|
|
@ -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
Loading…
Add table
Reference in a new issue