Merge remote-tracking branch 'origin/main' into will/edit-w-checkpoints

This commit is contained in:
Matt Rubens 2025-07-22 14:51:14 -04:00
commit ab46f94c26
160 changed files with 3741 additions and 683 deletions

14
.gitattributes vendored
View file

@ -4,3 +4,17 @@ src/assets/docs/demo.gif filter=lfs diff=lfs merge=lfs -text
# Test snapshot files - mark as linguist-generated to exclude from GitHub language statistics
*.snap linguist-generated=true
# Non-English translation files - mark as linguist-generated to exclude from GitHub language statistics
# Root locales directory (contains only non-English translations)
locales/** linguist-generated=true
# Mark all locale directories as generated first
src/i18n/locales/** linguist-generated=true
webview-ui/src/i18n/locales/** linguist-generated=true
# Then explicitly mark English directories as NOT generated (override the above)
src/i18n/locales/en/** linguist-generated=false
webview-ui/src/i18n/locales/en/** linguist-generated=false
# This approach uses gitattributes' last-match-wins rule to exclude English while including all other locales

View file

@ -6,12 +6,12 @@
- Ensure all tests pass before submitting changes
- The vitest framework is used for testing; the `describe`, `test`, `it`, etc functions are defined by default in `tsconfig.json` and therefore don't need to be imported
- Tests must be run from the same directory as the `package.json` file that specifies `vitest` in `devDependencies`
- Run tests with: `npx vitest <relative-path-from-workspace-root>`
- Run tests with: `npx vitest run <relative-path-from-workspace-root>`
- Do NOT run tests from project root - this causes "vitest: command not found" error
- Tests must be run from inside the correct workspace:
- Backend tests: `cd src && npx vitest path/to/test-file` (don't include `src/` in path)
- UI tests: `cd webview-ui && npx vitest src/path/to/test-file`
- Example: For `src/tests/user.test.ts`, run `cd src && npx vitest tests/user.test.ts` NOT `npx vitest src/tests/user.test.ts`
- Backend tests: `cd src && npx vitest run path/to/test-file` (don't include `src/` in path)
- UI tests: `cd webview-ui && npx vitest run src/path/to/test-file`
- Example: For `src/tests/user.test.ts`, run `cd src && npx vitest run tests/user.test.ts` NOT `npx vitest run src/tests/user.test.ts`
2. Lint Rules:

View file

@ -75,11 +75,27 @@ customModes:
whenToUse: Automate the release process for software projects.
description: Automate the release process.
customInstructions: |-
When preparing a release: 1. Identify the SHA corresponding to the most recent release using GitHub CLI: `gh release view --json tagName,targetCommitish,publishedAt ` 2. Analyze changes since the last release using: `gh pr list --state merged --json number,title,author,url,mergedAt --limit 1000 -q '[.[] | select(.mergedAt > "TIMESTAMP") | {number, title, author: .author.login, url, mergedAt}] | sort_by(.number)'` 3. Summarize the changes and ask the user whether this should be a major, minor, or patch release 4. Create a changeset in .changeset/v[version].md instead of directly modifying package.json. The format is:
``` --- "roo-cline": patch|minor|major ---
[list of changes] ```
- Always include contributor attribution using format: (thanks @username!) - Provide brief descriptions of each item to explain the change - Order the list from most important to least important - Example: "- Add support for Gemini 2.5 Pro caching (thanks @contributor!)" - CRITICAL: Include EVERY SINGLE PR in the changeset - don't assume you know which ones are important. Count the total PRs to verify completeness and cross-reference the list to ensure nothing is missed.
5. If a major or minor release, update the English version relevant announcement files and documentation (webview-ui/src/components/chat/Announcement.tsx, README.md, and the `latestAnnouncementId` in src/core/webview/ClineProvider.ts) 6. Ask the user to confirm the English version 7. Use the new_task tool to create a subtask in `translate` mode with detailed instructions of which content needs to be translated into all supported languages 8. Commit and push the changeset file to the repository 9. The GitHub Actions workflow will automatically:
When preparing a release:
1. Identify the SHA corresponding to the most recent release using GitHub CLI: `gh release view --json tagName,targetCommitish,publishedAt`
2. Analyze changes since the last release using: `gh pr list --state merged --json number,title,author,url,mergedAt,closingIssuesReferences --limit 1000 -q '[.[] | select(.mergedAt > "TIMESTAMP") | {number, title, author: .author.login, url, mergedAt, issues: .closingIssuesReferences}] | sort_by(.number)'`
3. For each PR with linked issues, fetch the issue details to get the issue reporter: `gh issue view ISSUE_NUMBER --json number,author -q '{number, reporter: .author.login}'`
4. Summarize the changes and ask the user whether this should be a major, minor, or patch release
5. Create a changeset in .changeset/v[version].md instead of directly modifying package.json. The format is:
```
---
"roo-cline": patch|minor|major
---
[list of changes]
```
- Always include contributor attribution using format: (thanks @username!) - For PRs that close issues, also include the issue number and reporter: "- Fix: Description (#123 by @reporter, PR by @contributor)" - For PRs without linked issues, use the standard format: "- Add support for feature (thanks @contributor!)" - Provide brief descriptions of each item to explain the change - Order the list from most important to least important - Example formats:
- With issue: "- Fix: Resolve memory leak in extension (#456 by @issueReporter, PR by @prAuthor)"
- Without issue: "- Add support for Gemini 2.5 Pro caching (thanks @contributor!)"
- CRITICAL: Include EVERY SINGLE PR in the changeset - don't assume you know which ones are important. Count the total PRs to verify completeness and cross-reference the list to ensure nothing is missed.
6. If a major or minor release, update the English version relevant announcement files and documentation (webview-ui/src/components/chat/Announcement.tsx, README.md, and the `latestAnnouncementId` in src/core/webview/ClineProvider.ts)
7. Ask the user to confirm the English version
8. Use the new_task tool to create a subtask in `translate` mode with detailed instructions of which content needs to be translated into all supported languages
9. Create a new branch for the release preparation: `git checkout -b release/v[version]`
10. Commit and push the changeset file and any documentation updates to the repository: `git add . && git commit -m "chore: add changeset for v[version]" && git push origin release/v[version]` 11. Create a pull request for the release: `gh pr create --title "Release v[version]" --body "Release preparation for v[version]. This PR includes the changeset and any necessary documentation updates." --base main --head release/v[version]` 12. The GitHub Actions workflow will automatically:
- Create a version bump PR when changesets are merged to main
- Update the CHANGELOG.md with proper formatting
- Publish the release when the version bump PR is merged

View file

@ -1,5 +1,21 @@
# Roo Code Changelog
## [3.23.16] - 2025-07-19
- Add global rate limiting for OpenAI-compatible embeddings (thanks @daniel-lxs!)
- Add batch limiting to code indexer (thanks @daniel-lxs!)
- Fix Docker port conflicts for evals services
## [3.23.15] - 2025-07-18
- Fix configurable delay for diagnostics to prevent premature error reporting
- Add command timeout allowlist
- Add description and whenToUse fields to custom modes in .roomodes (thanks @RandalSchwartz!)
- Fix Claude model detection by name for API protocol selection (thanks @daniel-lxs!)
- Move marketplace icon from overflow menu to top navigation
- Optional setting to prevent completion with open todos
- Added YouTube to website footer (thanks @thill2323!)
## [3.23.14] - 2025-07-17
- Log api-initiated tasks to a tmp directory

View file

@ -7,13 +7,13 @@ fi
if ! nc -z localhost 5432 2>/dev/null; then
echo "❌ PostgreSQL is not running on port 5432"
echo "💡 Start it with: pnpm --filter @roo-code/evals db:start"
echo "💡 Start it with: pnpm --filter @roo-code/evals db:up"
exit 1
fi
if ! nc -z localhost 6379 2>/dev/null; then
echo "❌ Redis is not running on port 6379"
echo "💡 Start it with: pnpm --filter @roo-code/evals redis:start"
echo "💡 Start it with: pnpm --filter @roo-code/evals redis:up"
exit 1
fi

View file

@ -22,9 +22,10 @@ import { CreateRun } from "@/lib/schemas"
const EVALS_REPO_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../../evals")
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function createRun({ suite, exercises = [], systemPrompt, ...values }: CreateRun) {
export async function createRun({ suite, exercises = [], systemPrompt, timeout, ...values }: CreateRun) {
const run = await _createRun({
...values,
timeout,
socketPath: "", // TODO: Get rid of this.
})

View file

@ -21,6 +21,9 @@ import {
CONCURRENCY_MIN,
CONCURRENCY_MAX,
CONCURRENCY_DEFAULT,
TIMEOUT_MIN,
TIMEOUT_MAX,
TIMEOUT_DEFAULT,
} from "@/lib/schemas"
import { cn } from "@/lib/utils"
import { useOpenRouterModels } from "@/hooks/use-open-router-models"
@ -77,6 +80,7 @@ export function NewRun() {
exercises: [],
settings: undefined,
concurrency: CONCURRENCY_DEFAULT,
timeout: TIMEOUT_DEFAULT,
},
})
@ -341,6 +345,29 @@ export function NewRun() {
)}
/>
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (Minutes)</FormLabel>
<FormControl>
<div className="flex flex-row items-center gap-2">
<Slider
defaultValue={[field.value]}
min={TIMEOUT_MIN}
max={TIMEOUT_MAX}
step={1}
onValueChange={(value) => field.onChange(value[0])}
/>
<div>{field.value}</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"

View file

@ -12,6 +12,10 @@ export const CONCURRENCY_MIN = 1
export const CONCURRENCY_MAX = 25
export const CONCURRENCY_DEFAULT = 1
export const TIMEOUT_MIN = 5
export const TIMEOUT_MAX = 10
export const TIMEOUT_DEFAULT = 5
export const createRunSchema = z
.object({
model: z.string().min(1, { message: "Model is required." }),
@ -20,6 +24,7 @@ export const createRunSchema = z
exercises: z.array(z.string()).optional(),
settings: rooCodeSettingsSchema.optional(),
concurrency: z.number().int().min(CONCURRENCY_MIN).max(CONCURRENCY_MAX),
timeout: z.number().int().min(TIMEOUT_MIN).max(TIMEOUT_MAX),
systemPrompt: z.string().optional(),
})
.refine((data) => data.suite === "full" || (data.exercises || []).length > 0, {

View file

@ -4,7 +4,7 @@ import { useState, useRef, useEffect } from "react"
import Link from "next/link"
import Image from "next/image"
import { ChevronDown } from "lucide-react"
import { FaBluesky, FaDiscord, FaGithub, FaLinkedin, FaReddit, FaTiktok, FaXTwitter } from "react-icons/fa6"
import { FaBluesky, FaDiscord, FaGithub, FaLinkedin, FaReddit, FaTiktok, FaXTwitter, FaYoutube } from "react-icons/fa6"
import { EXTERNAL_LINKS, INTERNAL_LINKS } from "@/lib/constants"
import { useLogoSrc } from "@/lib/hooks/use-logo-src"
@ -80,6 +80,14 @@ export function Footer() {
<FaLinkedin className="h-6 w-6" />
<span className="sr-only">LinkedIn</span>
</a>
<a
href={EXTERNAL_LINKS.BLUESKY}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground transition-colors hover:text-foreground">
<FaBluesky className="h-6 w-6" />
<span className="sr-only">Bluesky</span>
</a>
<a
href={EXTERNAL_LINKS.TIKTOK}
target="_blank"
@ -89,12 +97,12 @@ export function Footer() {
<span className="sr-only">TikTok</span>
</a>
<a
href={EXTERNAL_LINKS.BLUESKY}
href={EXTERNAL_LINKS.YOUTUBE}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground transition-colors hover:text-foreground">
<FaBluesky className="h-6 w-6" />
<span className="sr-only">Bluesky</span>
<FaYoutube className="h-6 w-6" />
<span className="sr-only">YouTube</span>
</a>
</div>
</div>

View file

@ -6,6 +6,7 @@ export const EXTERNAL_LINKS = {
LINKEDIN: "https://www.linkedin.com/company/roo-code",
TIKTOK: "https://www.tiktok.com/@roo.code",
BLUESKY: "https://bsky.app/profile/roocode.bsky.social",
YOUTUBE: "https://www.youtube.com/@RooCodeYT",
DOCUMENTATION: "https://docs.roocode.com",
CAREERS: "https://careers.roocode.com",
ISSUES: "https://github.com/RooCodeInc/Roo-Code/issues",

View file

@ -23,7 +23,7 @@
"changeset:version": "cp CHANGELOG.md src/CHANGELOG.md && changeset version && cp -vf src/CHANGELOG.md .",
"knip": "knip --include files",
"update-contributors": "node scripts/update-contributors.js",
"evals": "docker compose -f packages/evals/docker-compose.yml --profile server --profile runner up --build --scale runner=0"
"evals": "dotenvx run -f packages/evals/.env.development packages/evals/.env.local -- docker compose -f packages/evals/docker-compose.yml --profile server --profile runner up --build --scale runner=0"
},
"devDependencies": {
"@changesets/cli": "^2.27.10",

View file

@ -494,7 +494,7 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
signal: AbortSignal.timeout(10000),
})
if (response.status >= 400 && response.status < 500) {
if (response.status === 401 || response.status === 404) {
throw new InvalidClientTokenError()
} else if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)

View file

@ -1 +1,3 @@
DATABASE_URL=postgres://postgres:password@localhost:5432/evals_development
DATABASE_URL=postgres://postgres:password@localhost:5433/evals_development
EVALS_DB_PORT=5433
EVALS_REDIS_PORT=6380

View file

@ -1 +1,3 @@
DATABASE_URL=postgres://postgres:password@localhost:5432/evals_test
DATABASE_URL=postgres://postgres:password@localhost:5433/evals_test
EVALS_DB_PORT=5433
EVALS_REDIS_PORT=6380

View file

@ -89,6 +89,46 @@ The setup script does the following:
- Prompts for an OpenRouter API key to add to `.env.local`
- Optionally builds and installs the Roo Code extension from source
## Port Configuration
By default, the evals system uses the following ports:
- **PostgreSQL**: 5433 (external) → 5432 (internal)
- **Redis**: 6380 (external) → 6379 (internal)
- **Web Service**: 3446 (external) → 3000 (internal)
These ports are configured to avoid conflicts with other services that might be running on the standard PostgreSQL (5432) and Redis (6379) ports.
### Customizing Ports
If you need to use different ports, you can customize them by creating a `.env.local` file in the `packages/evals/` directory:
```sh
# Copy the example file and customize as needed
cp packages/evals/.env.local.example packages/evals/.env.local
```
Then edit `.env.local` to set your preferred ports:
```sh
# Custom port configuration
EVALS_DB_PORT=5434
EVALS_REDIS_PORT=6381
EVALS_WEB_PORT=3447
# Optional: Override database URL if needed
DATABASE_URL=postgres://postgres:password@localhost:5434/evals_development
```
### Port Conflict Resolution
If you encounter port conflicts when running `pnpm evals`, you have several options:
1. **Use the default configuration** (recommended): The system now uses non-standard ports by default
2. **Stop conflicting services**: Temporarily stop other PostgreSQL/Redis services
3. **Customize ports**: Use the `.env.local` file to set different ports
4. **Use Docker networks**: Run services in isolated Docker networks
## Troubleshooting
Here are some errors that you might encounter along with potential fixes:

View file

@ -18,11 +18,12 @@
"db:push": "pnpm drizzle-kit push",
"db:test:push": "pnpm drizzle-kit:test push",
"db:production:push": "pnpm drizzle-kit:production push",
"db:start": "docker compose up -d db",
"db:stop": "docker compose down db",
"redis:start": "docker compose up -d redis",
"redis:stop": "docker compose down redis",
"services:start": "docker compose up -d db redis"
"db:up": "dotenvx run -f .env.development .env.local -- docker compose up -d db",
"db:down": "dotenvx run -f .env.development .env.local -- docker compose down db",
"redis:up": "dotenvx run -f .env.development .env.local -- docker compose up -d redis",
"redis:down": "dotenvx run -f .env.development .env.local -- docker compose down redis",
"services:up": "dotenvx run -f .env.development .env.local -- docker compose up -d db redis",
"services:down": "dotenvx run -f .env.development .env.local -- docker compose down db redis"
},
"dependencies": {
"@roo-code/ipc": "workspace:^",

View file

@ -1,7 +1,5 @@
import { createClient, type RedisClientType } from "redis"
import { EVALS_TIMEOUT } from "@roo-code/types"
let redis: RedisClientType | undefined
export const redisClient = async () => {
@ -18,11 +16,19 @@ export const getPubSubKey = (runId: number) => `evals:${runId}`
export const getRunnersKey = (runId: number) => `runners:${runId}`
export const getHeartbeatKey = (runId: number) => `heartbeat:${runId}`
export const registerRunner = async ({ runId, taskId }: { runId: number; taskId: number }) => {
export const registerRunner = async ({
runId,
taskId,
timeoutSeconds,
}: {
runId: number
taskId: number
timeoutSeconds: number
}) => {
const redis = await redisClient()
const runnersKey = getRunnersKey(runId)
await redis.sAdd(runnersKey, `task-${taskId}:${process.env.HOSTNAME ?? process.pid}`)
await redis.expire(runnersKey, EVALS_TIMEOUT / 1_000)
await redis.expire(runnersKey, timeoutSeconds)
}
export const deregisterRunner = async ({ runId, taskId }: { runId: number; taskId: number }) => {

View file

@ -5,14 +5,7 @@ import * as os from "node:os"
import pWaitFor from "p-wait-for"
import { execa } from "execa"
import {
type TaskEvent,
TaskCommandName,
RooCodeEventName,
IpcMessageType,
EVALS_SETTINGS,
EVALS_TIMEOUT,
} from "@roo-code/types"
import { type TaskEvent, TaskCommandName, RooCodeEventName, IpcMessageType, EVALS_SETTINGS } from "@roo-code/types"
import { IpcClient } from "@roo-code/ipc"
import {
@ -42,7 +35,7 @@ export const processTask = async ({ taskId, logger }: { taskId: number; logger?:
const task = await findTask(taskId)
const { language, exercise } = task
const run = await findRun(task.runId)
await registerRunner({ runId: run.id, taskId })
await registerRunner({ runId: run.id, taskId, timeoutSeconds: (run.timeout || 5) * 60 })
const containerized = isDockerContainer()
@ -304,9 +297,10 @@ export const runTask = async ({ run, task, publish, logger }: RunTaskOptions) =>
})
try {
const timeoutMs = (run.timeout || 5) * 60 * 1_000 // Convert minutes to milliseconds
await pWaitFor(() => !!taskFinishedAt || !!taskAbortedAt || isClientDisconnected, {
interval: 1_000,
timeout: EVALS_TIMEOUT,
timeout: timeoutMs,
})
} catch (_error) {
taskTimedOut = true

View file

@ -0,0 +1 @@
ALTER TABLE "runs" ADD COLUMN "timeout" integer DEFAULT 5 NOT NULL;

View file

@ -0,0 +1 @@
ALTER TABLE "runs" ADD COLUMN "timeout" integer DEFAULT 5 NOT NULL;

View file

@ -0,0 +1,417 @@
{
"id": "43b197c4-ff4f-48c1-908b-a330e66a162d",
"prevId": "b50d5e6a-0f3f-4605-a5e7-9351711fc5e4",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.runs": {
"name": "runs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "runs_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"task_metrics_id": {
"name": "task_metrics_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"pid": {
"name": "pid",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"socket_path": {
"name": "socket_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"concurrency": {
"name": "concurrency",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 2
},
"timeout": {
"name": "timeout",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 5
},
"passed": {
"name": "passed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"failed": {
"name": "failed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"runs_task_metrics_id_taskMetrics_id_fk": {
"name": "runs_task_metrics_id_taskMetrics_id_fk",
"tableFrom": "runs",
"tableTo": "taskMetrics",
"columnsFrom": ["task_metrics_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.taskMetrics": {
"name": "taskMetrics",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "taskMetrics_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"tokens_in": {
"name": "tokens_in",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tokens_out": {
"name": "tokens_out",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tokens_context": {
"name": "tokens_context",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cache_writes": {
"name": "cache_writes",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cache_reads": {
"name": "cache_reads",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cost": {
"name": "cost",
"type": "real",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tool_usage": {
"name": "tool_usage",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tasks": {
"name": "tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tasks_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"run_id": {
"name": "run_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"task_metrics_id": {
"name": "task_metrics_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true
},
"exercise": {
"name": "exercise",
"type": "text",
"primaryKey": false,
"notNull": true
},
"passed": {
"name": "passed",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"finished_at": {
"name": "finished_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"tasks_language_exercise_idx": {
"name": "tasks_language_exercise_idx",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "language",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "exercise",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tasks_run_id_runs_id_fk": {
"name": "tasks_run_id_runs_id_fk",
"tableFrom": "tasks",
"tableTo": "runs",
"columnsFrom": ["run_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"tasks_task_metrics_id_taskMetrics_id_fk": {
"name": "tasks_task_metrics_id_taskMetrics_id_fk",
"tableFrom": "tasks",
"tableTo": "taskMetrics",
"columnsFrom": ["task_metrics_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.toolErrors": {
"name": "toolErrors",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "toolErrors_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"run_id": {
"name": "run_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"task_id": {
"name": "task_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"tool_name": {
"name": "tool_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"toolErrors_run_id_runs_id_fk": {
"name": "toolErrors_run_id_runs_id_fk",
"tableFrom": "toolErrors",
"tableTo": "runs",
"columnsFrom": ["run_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"toolErrors_task_id_tasks_id_fk": {
"name": "toolErrors_task_id_tasks_id_fk",
"tableFrom": "toolErrors",
"tableTo": "tasks",
"columnsFrom": ["task_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1748937674449,
"tag": "0000_young_trauma",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1753198630651,
"tag": "0001_lowly_captain_flint",
"breakpoints": true
}
]
}

View file

@ -23,6 +23,7 @@ describe("copyRun", () => {
socketPath: "/tmp/roo.sock",
description: "Test run for copying",
concurrency: 4,
timeout: 5,
})
sourceRunId = run.id
@ -271,7 +272,7 @@ describe("copyRun", () => {
})
it("should copy run without task metrics", async () => {
const minimalRun = await createRun({ model: "gpt-3.5-turbo", socketPath: "/tmp/minimal.sock" })
const minimalRun = await createRun({ model: "gpt-3.5-turbo", socketPath: "/tmp/minimal.sock", timeout: 5 })
const newRunId = await copyRun({ sourceDb: db, targetDb: db, runId: minimalRun.id })

View file

@ -18,6 +18,7 @@ export const runs = pgTable("runs", {
pid: integer(),
socketPath: text("socket_path").notNull(),
concurrency: integer().default(2).notNull(),
timeout: integer().default(5).notNull(),
passed: integer().default(0).notNull(),
failed: integer().default(0).notNull(),
createdAt: timestamp("created_at").notNull(),

View file

@ -12,6 +12,12 @@ describe("getApiProtocol", () => {
expect(getApiProtocol("claude-code")).toBe("anthropic")
expect(getApiProtocol("claude-code", "some-model")).toBe("anthropic")
})
it("should return 'anthropic' for bedrock provider", () => {
expect(getApiProtocol("bedrock")).toBe("anthropic")
expect(getApiProtocol("bedrock", "gpt-4")).toBe("anthropic")
expect(getApiProtocol("bedrock", "claude-3-opus")).toBe("anthropic")
})
})
describe("Vertex provider with Claude models", () => {
@ -27,25 +33,14 @@ describe("getApiProtocol", () => {
expect(getApiProtocol("vertex", "gemini-pro")).toBe("openai")
expect(getApiProtocol("vertex", "llama-2")).toBe("openai")
})
})
describe("Bedrock provider with Claude models", () => {
it("should return 'anthropic' for bedrock provider with claude models", () => {
expect(getApiProtocol("bedrock", "claude-3-opus")).toBe("anthropic")
expect(getApiProtocol("bedrock", "Claude-3-Sonnet")).toBe("anthropic")
expect(getApiProtocol("bedrock", "CLAUDE-instant")).toBe("anthropic")
expect(getApiProtocol("bedrock", "anthropic.claude-v2")).toBe("anthropic")
})
it("should return 'openai' for bedrock provider with non-claude models", () => {
expect(getApiProtocol("bedrock", "gpt-4")).toBe("openai")
expect(getApiProtocol("bedrock", "titan-text")).toBe("openai")
expect(getApiProtocol("bedrock", "llama-2")).toBe("openai")
it("should return 'openai' for vertex provider without model", () => {
expect(getApiProtocol("vertex")).toBe("openai")
})
})
describe("Other providers with Claude models", () => {
it("should return 'openai' for non-vertex/bedrock providers with claude models", () => {
describe("Other providers", () => {
it("should return 'openai' for non-anthropic providers regardless of model", () => {
expect(getApiProtocol("openrouter", "claude-3-opus")).toBe("openai")
expect(getApiProtocol("openai", "claude-3-sonnet")).toBe("openai")
expect(getApiProtocol("litellm", "claude-instant")).toBe("openai")
@ -59,20 +54,13 @@ describe("getApiProtocol", () => {
expect(getApiProtocol(undefined, "claude-3-opus")).toBe("openai")
})
it("should return 'openai' when model is undefined", () => {
expect(getApiProtocol("openai")).toBe("openai")
expect(getApiProtocol("vertex")).toBe("openai")
expect(getApiProtocol("bedrock")).toBe("openai")
})
it("should handle empty strings", () => {
expect(getApiProtocol("vertex", "")).toBe("openai")
expect(getApiProtocol("bedrock", "")).toBe("openai")
})
it("should be case-insensitive for claude detection", () => {
expect(getApiProtocol("vertex", "CLAUDE-3-OPUS")).toBe("anthropic")
expect(getApiProtocol("bedrock", "claude-3-opus")).toBe("anthropic")
expect(getApiProtocol("vertex", "claude-3-opus")).toBe("anthropic")
expect(getApiProtocol("vertex", "ClAuDe-InStAnT")).toBe("anthropic")
})
})

View file

@ -21,7 +21,7 @@ export const CODEBASE_INDEX_DEFAULTS = {
export const codebaseIndexConfigSchema = z.object({
codebaseIndexEnabled: z.boolean().optional(),
codebaseIndexQdrantUrl: z.string().optional(),
codebaseIndexEmbedderProvider: z.enum(["openai", "ollama", "openai-compatible", "gemini"]).optional(),
codebaseIndexEmbedderProvider: z.enum(["openai", "ollama", "openai-compatible", "gemini", "mistral"]).optional(),
codebaseIndexEmbedderBaseUrl: z.string().optional(),
codebaseIndexEmbedderModelId: z.string().optional(),
codebaseIndexEmbedderModelDimension: z.number().optional(),
@ -47,6 +47,7 @@ export const codebaseIndexModelsSchema = z.object({
ollama: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
"openai-compatible": z.record(z.string(), z.object({ dimension: z.number() })).optional(),
gemini: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
mistral: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
})
export type CodebaseIndexModels = z.infer<typeof codebaseIndexModelsSchema>
@ -62,6 +63,7 @@ export const codebaseIndexProviderSchema = z.object({
codebaseIndexOpenAiCompatibleApiKey: z.string().optional(),
codebaseIndexOpenAiCompatibleModelDimension: z.number().optional(),
codebaseIndexGeminiApiKey: z.string().optional(),
codebaseIndexMistralApiKey: z.string().optional(),
})
export type CodebaseIndexProvider = z.infer<typeof codebaseIndexProviderSchema>

View file

@ -22,6 +22,13 @@ import { languagesSchema } from "./vscode.js"
*/
export const DEFAULT_WRITE_DELAY_MS = 1000
/**
* Default terminal output character limit constant.
* This provides a reasonable default that aligns with typical terminal usage
* while preventing context window explosions from extremely long lines.
*/
export const DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT = 50_000
/**
* GlobalSettings
*/
@ -85,6 +92,7 @@ export const globalSettingsSchema = z.object({
maxReadFileLine: z.number().optional(),
terminalOutputLineLimit: z.number().optional(),
terminalOutputCharacterLimit: z.number().optional(),
terminalShellIntegrationTimeout: z.number().optional(),
terminalShellIntegrationDisabled: z.boolean().optional(),
terminalCommandDelay: z.number().optional(),
@ -151,6 +159,7 @@ export const SECRET_STATE_KEYS = [
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
"moonshotApiKey",
"mistralApiKey",
"unboundApiKey",
"requestyApiKey",
@ -162,6 +171,7 @@ export const SECRET_STATE_KEYS = [
"codeIndexQdrantApiKey",
"codebaseIndexOpenAiCompatibleApiKey",
"codebaseIndexGeminiApiKey",
"codebaseIndexMistralApiKey",
] as const satisfies readonly (keyof ProviderSettings)[]
export type SecretState = Pick<ProviderSettings, (typeof SECRET_STATE_KEYS)[number]>
@ -227,6 +237,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
soundVolume: 0.5,
terminalOutputLineLimit: 500,
terminalOutputCharacterLimit: DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
terminalShellIntegrationTimeout: 30000,
terminalCommandDelay: 0,
terminalPowershellCounter: false,

View file

@ -22,6 +22,7 @@ export const providerNames = [
"gemini-cli",
"openai-native",
"mistral",
"moonshot",
"deepseek",
"unbound",
"requesty",
@ -61,6 +62,7 @@ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3
const baseProviderSettingsSchema = z.object({
includeMaxTokens: z.boolean().optional(),
diffEnabled: z.boolean().optional(),
todoListEnabled: z.boolean().optional(),
fuzzyMatchThreshold: z.number().optional(),
modelTemperature: z.number().nullish(),
rateLimitSeconds: z.number().optional(),
@ -186,6 +188,13 @@ const deepSeekSchema = apiModelIdProviderModelSchema.extend({
deepSeekApiKey: z.string().optional(),
})
const moonshotSchema = apiModelIdProviderModelSchema.extend({
moonshotBaseUrl: z
.union([z.literal("https://api.moonshot.ai/v1"), z.literal("https://api.moonshot.cn/v1")])
.optional(),
moonshotApiKey: z.string().optional(),
})
const unboundSchema = baseProviderSettingsSchema.extend({
unboundApiKey: z.string().optional(),
unboundModelId: z.string().optional(),
@ -240,6 +249,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
openAiNativeSchema.merge(z.object({ apiProvider: z.literal("openai-native") })),
mistralSchema.merge(z.object({ apiProvider: z.literal("mistral") })),
deepSeekSchema.merge(z.object({ apiProvider: z.literal("deepseek") })),
moonshotSchema.merge(z.object({ apiProvider: z.literal("moonshot") })),
unboundSchema.merge(z.object({ apiProvider: z.literal("unbound") })),
requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })),
humanRelaySchema.merge(z.object({ apiProvider: z.literal("human-relay") })),
@ -268,6 +278,7 @@ export const providerSettingsSchema = z.object({
...openAiNativeSchema.shape,
...mistralSchema.shape,
...deepSeekSchema.shape,
...moonshotSchema.shape,
...unboundSchema.shape,
...requestySchema.shape,
...humanRelaySchema.shape,
@ -301,7 +312,7 @@ export const getModelId = (settings: ProviderSettings): string | undefined => {
}
// Providers that use Anthropic-style API protocol
export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "claude-code"]
export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "claude-code", "bedrock"]
// Helper function to determine API protocol for a provider and model
export const getApiProtocol = (provider: ProviderName | undefined, modelId?: string): "anthropic" | "openai" => {
@ -310,13 +321,8 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str
return "anthropic"
}
// For vertex and bedrock providers, check if the model ID contains "claude" (case-insensitive)
if (
provider &&
(provider === "vertex" || provider === "bedrock") &&
modelId &&
modelId.toLowerCase().includes("claude")
) {
// For vertex provider, check if the model ID contains "claude" (case-insensitive)
if (provider && provider === "vertex" && modelId && modelId.toLowerCase().includes("claude")) {
return "anthropic"
}

View file

@ -9,6 +9,7 @@ export * from "./groq.js"
export * from "./lite-llm.js"
export * from "./lm-studio.js"
export * from "./mistral.js"
export * from "./moonshot.js"
export * from "./ollama.js"
export * from "./openai.js"
export * from "./openrouter.js"

View file

@ -0,0 +1,22 @@
import type { ModelInfo } from "../model.js"
// https://platform.moonshot.ai/
export type MoonshotModelId = keyof typeof moonshotModels
export const moonshotDefaultModelId: MoonshotModelId = "kimi-k2-0711-preview"
export const moonshotModels = {
"kimi-k2-0711-preview": {
maxTokens: 32_000,
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0.6, // $0.60 per million tokens (cache miss)
outputPrice: 2.5, // $2.50 per million tokens
cacheWritesPrice: 0, // $0 per million tokens (cache miss)
cacheReadsPrice: 0.15, // $0.15 per million tokens (cache hit)
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.`,
},
} as const satisfies Record<string, ModelInfo>
export const MOONSHOT_DEFAULT_TEMPERATURE = 0.6

View file

@ -17,6 +17,7 @@ import {
GeminiHandler,
OpenAiNativeHandler,
DeepSeekHandler,
MoonshotHandler,
MistralHandler,
VsCodeLmHandler,
UnboundHandler,
@ -89,6 +90,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
return new OpenAiNativeHandler(options)
case "deepseek":
return new DeepSeekHandler(options)
case "moonshot":
return new MoonshotHandler(options)
case "vscode-lm":
return new VsCodeLmHandler(options)
case "mistral":
@ -110,6 +113,7 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
case "litellm":
return new LiteLLMHandler(options)
default:
apiProvider satisfies "gemini-cli" | undefined
return new AnthropicHandler(options)
}
}

View file

@ -0,0 +1,297 @@
// Mocks must come first, before imports
const mockCreate = vi.fn()
vi.mock("openai", () => {
return {
__esModule: true,
default: vi.fn().mockImplementation(() => ({
chat: {
completions: {
create: mockCreate.mockImplementation(async (options) => {
if (!options.stream) {
return {
id: "test-completion",
choices: [
{
message: { role: "assistant", content: "Test response", refusal: null },
finish_reason: "stop",
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
cached_tokens: 2,
},
}
}
// Return async iterator for streaming
return {
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { content: "Test response" },
index: 0,
},
],
usage: null,
}
yield {
choices: [
{
delta: {},
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
cached_tokens: 2,
},
}
},
}
}),
},
},
})),
}
})
import OpenAI from "openai"
import type { Anthropic } from "@anthropic-ai/sdk"
import { moonshotDefaultModelId } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../../shared/api"
import { MoonshotHandler } from "../moonshot"
describe("MoonshotHandler", () => {
let handler: MoonshotHandler
let mockOptions: ApiHandlerOptions
beforeEach(() => {
mockOptions = {
moonshotApiKey: "test-api-key",
apiModelId: "moonshot-chat",
moonshotBaseUrl: "https://api.moonshot.ai/v1",
}
handler = new MoonshotHandler(mockOptions)
vi.clearAllMocks()
})
describe("constructor", () => {
it("should initialize with provided options", () => {
expect(handler).toBeInstanceOf(MoonshotHandler)
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
})
it.skip("should throw error if API key is missing", () => {
expect(() => {
new MoonshotHandler({
...mockOptions,
moonshotApiKey: undefined,
})
}).toThrow("Moonshot API key is required")
})
it("should use default model ID if not provided", () => {
const handlerWithoutModel = new MoonshotHandler({
...mockOptions,
apiModelId: undefined,
})
expect(handlerWithoutModel.getModel().id).toBe(moonshotDefaultModelId)
})
it("should use default base URL if not provided", () => {
const handlerWithoutBaseUrl = new MoonshotHandler({
...mockOptions,
moonshotBaseUrl: undefined,
})
expect(handlerWithoutBaseUrl).toBeInstanceOf(MoonshotHandler)
// The base URL is passed to OpenAI client internally
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://api.moonshot.ai/v1",
}),
)
})
it("should use chinese base URL if provided", () => {
const customBaseUrl = "https://api.moonshot.cn/v1"
const handlerWithCustomUrl = new MoonshotHandler({
...mockOptions,
moonshotBaseUrl: customBaseUrl,
})
expect(handlerWithCustomUrl).toBeInstanceOf(MoonshotHandler)
// The custom base URL is passed to OpenAI client
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: customBaseUrl,
}),
)
})
it("should set includeMaxTokens to true", () => {
// Create a new handler and verify OpenAI client was called with includeMaxTokens
const _handler = new MoonshotHandler(mockOptions)
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: mockOptions.moonshotApiKey }))
})
})
describe("getModel", () => {
it("should return model info for valid model ID", () => {
const model = handler.getModel()
expect(model.id).toBe(mockOptions.apiModelId)
expect(model.info).toBeDefined()
expect(model.info.maxTokens).toBe(32_000)
expect(model.info.contextWindow).toBe(131_072)
expect(model.info.supportsImages).toBe(false)
expect(model.info.supportsPromptCache).toBe(true) // Should be true now
})
it("should return provided model ID with default model info if model does not exist", () => {
const handlerWithInvalidModel = new MoonshotHandler({
...mockOptions,
apiModelId: "invalid-model",
})
const model = handlerWithInvalidModel.getModel()
expect(model.id).toBe("invalid-model") // Returns provided ID
expect(model.info).toBeDefined()
// With the current implementation, it's the same object reference when using default model info
expect(model.info).toBe(handler.getModel().info)
// Should have the same base properties
expect(model.info.contextWindow).toBe(handler.getModel().info.contextWindow)
// And should have supportsPromptCache set to true
expect(model.info.supportsPromptCache).toBe(true)
})
it("should return default model if no model ID is provided", () => {
const handlerWithoutModel = new MoonshotHandler({
...mockOptions,
apiModelId: undefined,
})
const model = handlerWithoutModel.getModel()
expect(model.id).toBe(moonshotDefaultModelId)
expect(model.info).toBeDefined()
expect(model.info.supportsPromptCache).toBe(true)
})
it("should include model parameters from getModelParams", () => {
const model = handler.getModel()
expect(model).toHaveProperty("temperature")
expect(model).toHaveProperty("maxTokens")
})
})
describe("createMessage", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text" as const,
text: "Hello!",
},
],
},
]
it("should handle streaming responses", async () => {
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.length).toBeGreaterThan(0)
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Test response")
})
it("should include usage information", async () => {
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks.length).toBeGreaterThan(0)
expect(usageChunks[0].inputTokens).toBe(10)
expect(usageChunks[0].outputTokens).toBe(5)
})
it("should include cache metrics in usage information", async () => {
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks.length).toBeGreaterThan(0)
expect(usageChunks[0].cacheWriteTokens).toBe(0)
expect(usageChunks[0].cacheReadTokens).toBe(2)
})
})
describe("processUsageMetrics", () => {
it("should correctly process usage metrics including cache information", () => {
// We need to access the protected method, so we'll create a test subclass
class TestMoonshotHandler extends MoonshotHandler {
public testProcessUsageMetrics(usage: any) {
return this.processUsageMetrics(usage)
}
}
const testHandler = new TestMoonshotHandler(mockOptions)
const usage = {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
cached_tokens: 20,
}
const result = testHandler.testProcessUsageMetrics(usage)
expect(result.type).toBe("usage")
expect(result.inputTokens).toBe(100)
expect(result.outputTokens).toBe(50)
expect(result.cacheWriteTokens).toBe(0)
expect(result.cacheReadTokens).toBe(20)
})
it("should handle missing cache metrics gracefully", () => {
class TestMoonshotHandler extends MoonshotHandler {
public testProcessUsageMetrics(usage: any) {
return this.processUsageMetrics(usage)
}
}
const testHandler = new TestMoonshotHandler(mockOptions)
const usage = {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
// No cached_tokens
}
const result = testHandler.testProcessUsageMetrics(usage)
expect(result.type).toBe("usage")
expect(result.inputTokens).toBe(100)
expect(result.outputTokens).toBe(50)
expect(result.cacheWriteTokens).toBe(0)
expect(result.cacheReadTokens).toBeUndefined()
})
})
})

View file

@ -4,6 +4,7 @@ export { AwsBedrockHandler } from "./bedrock"
export { ChutesHandler } from "./chutes"
export { ClaudeCodeHandler } from "./claude-code"
export { DeepSeekHandler } from "./deepseek"
export { MoonshotHandler } from "./moonshot"
export { FakeAIHandler } from "./fake-ai"
export { GeminiHandler } from "./gemini"
export { GlamaHandler } from "./glama"

View file

@ -0,0 +1,39 @@
import { moonshotModels, moonshotDefaultModelId } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import type { ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { OpenAiHandler } from "./openai"
export class MoonshotHandler extends OpenAiHandler {
constructor(options: ApiHandlerOptions) {
super({
...options,
openAiApiKey: options.moonshotApiKey ?? "not-provided",
openAiModelId: options.apiModelId ?? moonshotDefaultModelId,
openAiBaseUrl: options.moonshotBaseUrl ?? "https://api.moonshot.ai/v1",
openAiStreamingEnabled: true,
includeMaxTokens: true,
})
}
override getModel() {
const id = this.options.apiModelId ?? moonshotDefaultModelId
const info = moonshotModels[id as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId]
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
return { id, info, ...params }
}
// Override to handle Moonshot's usage metrics, including caching.
protected override processUsageMetrics(usage: any): ApiStreamUsageChunk {
return {
type: "usage",
inputTokens: usage?.prompt_tokens || 0,
outputTokens: usage?.completion_tokens || 0,
cacheWriteTokens: 0,
cacheReadTokens: usage?.cached_tokens,
}
}
}

View file

@ -558,48 +558,54 @@ export class CustomModesManager {
*/
public async checkRulesDirectoryHasContent(slug: string): Promise<boolean> {
try {
// Get workspace path
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return false
}
// First, find the mode to determine its source
const allModes = await this.getCustomModes()
const mode = allModes.find((m) => m.slug === slug)
// Check if .roomodes file exists and contains this mode
// This ensures we can only consolidate rules for modes that have been customized
const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
try {
const roomodesExists = await fileExistsAtPath(roomodesPath)
if (roomodesExists) {
const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
const roomodesData = yaml.parse(roomodesContent)
const roomodesModes = roomodesData?.customModes || []
// Check if this specific mode exists in .roomodes
const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug)
if (!modeInRoomodes) {
return false // Mode not customized in .roomodes, cannot consolidate
}
} else {
// If no .roomodes file exists, check if it's in global custom modes
const allModes = await this.getCustomModes()
const mode = allModes.find((m) => m.slug === slug)
if (!mode) {
return false // Not a custom mode, cannot consolidate
}
if (!mode) {
// If not in custom modes, check if it's in .roomodes (project-specific)
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return false
}
} catch (error) {
// If we can't read .roomodes, fall back to checking custom modes
const allModes = await this.getCustomModes()
const mode = allModes.find((m) => m.slug === slug)
if (!mode) {
return false // Not a custom mode, cannot consolidate
const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
try {
const roomodesExists = await fileExistsAtPath(roomodesPath)
if (roomodesExists) {
const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
const roomodesData = yaml.parse(roomodesContent)
const roomodesModes = roomodesData?.customModes || []
// Check if this specific mode exists in .roomodes
const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug)
if (!modeInRoomodes) {
return false // Mode not found anywhere
}
} else {
return false // No .roomodes file and not in custom modes
}
} catch (error) {
return false // Cannot read .roomodes and not in custom modes
}
}
// Check for .roo/rules-{slug}/ directory
const modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
// Determine the correct rules directory based on mode source
let modeRulesDir: string
const isGlobalMode = mode?.source === "global"
if (isGlobalMode) {
// For global modes, check in global .roo directory
const globalRooDir = getGlobalRooDirectory()
modeRulesDir = path.join(globalRooDir, `rules-${slug}`)
} else {
// For project modes, check in workspace .roo directory
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return false
}
modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
}
try {
const stats = await fs.stat(modeRulesDir)
@ -655,24 +661,23 @@ export class CustomModesManager {
// If mode not found in custom modes, check if it's a built-in mode that has been customized
if (!mode) {
// Only check workspace-based modes if workspace is available
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return { success: false, error: "No workspace found" }
}
if (workspacePath) {
const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
try {
const roomodesExists = await fileExistsAtPath(roomodesPath)
if (roomodesExists) {
const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
const roomodesData = yaml.parse(roomodesContent)
const roomodesModes = roomodesData?.customModes || []
const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
try {
const roomodesExists = await fileExistsAtPath(roomodesPath)
if (roomodesExists) {
const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
const roomodesData = yaml.parse(roomodesContent)
const roomodesModes = roomodesData?.customModes || []
// Find the mode in .roomodes
mode = roomodesModes.find((m: any) => m.slug === slug)
// Find the mode in .roomodes
mode = roomodesModes.find((m: any) => m.slug === slug)
}
} catch (error) {
// Continue to check built-in modes
}
} catch (error) {
// Continue to check built-in modes
}
// If still not found, check if it's a built-in mode
@ -687,14 +692,25 @@ export class CustomModesManager {
}
}
// Get workspace path
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return { success: false, error: "No workspace found" }
// Determine the base directory based on mode source
const isGlobalMode = mode.source === "global"
let baseDir: string
if (isGlobalMode) {
// For global modes, use the global .roo directory
baseDir = getGlobalRooDirectory()
} else {
// For project modes, use the workspace directory
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return { success: false, error: "No workspace found" }
}
baseDir = workspacePath
}
// Check for .roo/rules-{slug}/ directory
const modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
// Check for .roo/rules-{slug}/ directory (or rules-{slug}/ for global)
const modeRulesDir = isGlobalMode
? path.join(baseDir, `rules-${slug}`)
: path.join(baseDir, ".roo", `rules-${slug}`)
let rulesFiles: RuleFile[] = []
try {
@ -709,8 +725,10 @@ export class CustomModesManager {
const filePath = path.join(modeRulesDir, entry.name)
const content = await fs.readFile(filePath, "utf-8")
if (content.trim()) {
// Calculate relative path from .roo directory
const relativePath = path.relative(path.join(workspacePath, ".roo"), filePath)
// Calculate relative path based on mode source
const relativePath = isGlobalMode
? path.relative(baseDir, filePath)
: path.relative(path.join(baseDir, ".roo"), filePath)
rulesFiles.push({ relativePath, content: content.trim() })
}
}
@ -755,6 +773,77 @@ export class CustomModesManager {
}
}
/**
* Helper method to import rules files for a mode
* @param importMode - The mode being imported
* @param rulesFiles - The rules files to import
* @param source - The import source ("global" or "project")
*/
private async importRulesFiles(
importMode: ExportedModeConfig,
rulesFiles: RuleFile[],
source: "global" | "project",
): Promise<void> {
// Determine base directory and rules folder path based on source
let baseDir: string
let rulesFolderPath: string
if (source === "global") {
baseDir = getGlobalRooDirectory()
rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
} else {
const workspacePath = getWorkspacePath()
baseDir = path.join(workspacePath, ".roo")
rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
}
// Always remove the existing rules folder for this mode if it exists
// This ensures that if the imported mode has no rules, the folder is cleaned up
try {
await fs.rm(rulesFolderPath, { recursive: true, force: true })
logger.info(`Removed existing ${source} rules folder for mode ${importMode.slug}`)
} catch (error) {
// It's okay if the folder doesn't exist
logger.debug(`No existing ${source} rules folder to remove for mode ${importMode.slug}`)
}
// Only proceed with file creation if there are rules files to import
if (!rulesFiles || !Array.isArray(rulesFiles) || rulesFiles.length === 0) {
return
}
// Import the new rules files with path validation
for (const ruleFile of rulesFiles) {
if (ruleFile.relativePath && ruleFile.content) {
// Validate the relative path to prevent path traversal attacks
const normalizedRelativePath = path.normalize(ruleFile.relativePath)
// Ensure the path doesn't contain traversal sequences
if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}
const targetPath = path.join(baseDir, normalizedRelativePath)
const normalizedTargetPath = path.normalize(targetPath)
const expectedBasePath = path.normalize(baseDir)
// Ensure the resolved path stays within the base directory
if (!normalizedTargetPath.startsWith(expectedBasePath)) {
logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}
// Ensure directory exists
const targetDir = path.dirname(targetPath)
await fs.mkdir(targetDir, { recursive: true })
// Write the file
await fs.writeFile(targetPath, ruleFile.content, "utf-8")
}
}
}
/**
* Imports modes from YAML content, including their associated rules files
* @param yamlContent - The YAML content containing mode configurations
@ -821,100 +910,8 @@ export class CustomModesManager {
source: source, // Use the provided source parameter
})
// Handle project-level imports
if (source === "project") {
const workspacePath = getWorkspacePath()
// Always remove the existing rules folder for this mode if it exists
// This ensures that if the imported mode has no rules, the folder is cleaned up
const rulesFolderPath = path.join(workspacePath, ".roo", `rules-${importMode.slug}`)
try {
await fs.rm(rulesFolderPath, { recursive: true, force: true })
logger.info(`Removed existing rules folder for mode ${importMode.slug}`)
} catch (error) {
// It's okay if the folder doesn't exist
logger.debug(`No existing rules folder to remove for mode ${importMode.slug}`)
}
// Only create new rules files if they exist in the import
if (rulesFiles && Array.isArray(rulesFiles) && rulesFiles.length > 0) {
// Import the new rules files with path validation
for (const ruleFile of rulesFiles) {
if (ruleFile.relativePath && ruleFile.content) {
// Validate the relative path to prevent path traversal attacks
const normalizedRelativePath = path.normalize(ruleFile.relativePath)
// Ensure the path doesn't contain traversal sequences
if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}
const targetPath = path.join(workspacePath, ".roo", normalizedRelativePath)
const normalizedTargetPath = path.normalize(targetPath)
const expectedBasePath = path.normalize(path.join(workspacePath, ".roo"))
// Ensure the resolved path stays within the .roo directory
if (!normalizedTargetPath.startsWith(expectedBasePath)) {
logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}
// Ensure directory exists
const targetDir = path.dirname(targetPath)
await fs.mkdir(targetDir, { recursive: true })
// Write the file
await fs.writeFile(targetPath, ruleFile.content, "utf-8")
}
}
}
} else if (source === "global" && rulesFiles && Array.isArray(rulesFiles)) {
// For global imports, preserve the rules files structure in the global .roo directory
const globalRooDir = getGlobalRooDirectory()
// Always remove the existing rules folder for this mode if it exists
// This ensures that if the imported mode has no rules, the folder is cleaned up
const rulesFolderPath = path.join(globalRooDir, `rules-${importMode.slug}`)
try {
await fs.rm(rulesFolderPath, { recursive: true, force: true })
logger.info(`Removed existing global rules folder for mode ${importMode.slug}`)
} catch (error) {
// It's okay if the folder doesn't exist
logger.debug(`No existing global rules folder to remove for mode ${importMode.slug}`)
}
// Import the new rules files with path validation
for (const ruleFile of rulesFiles) {
if (ruleFile.relativePath && ruleFile.content) {
// Validate the relative path to prevent path traversal attacks
const normalizedRelativePath = path.normalize(ruleFile.relativePath)
// Ensure the path doesn't contain traversal sequences
if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}
const targetPath = path.join(globalRooDir, normalizedRelativePath)
const normalizedTargetPath = path.normalize(targetPath)
const expectedBasePath = path.normalize(globalRooDir)
// Ensure the resolved path stays within the global .roo directory
if (!normalizedTargetPath.startsWith(expectedBasePath)) {
logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}
// Ensure directory exists
const targetDir = path.dirname(targetPath)
await fs.mkdir(targetDir, { recursive: true })
// Write the file
await fs.writeFile(targetPath, ruleFile.content, "utf-8")
}
}
}
// Import rules files (this also handles cleanup of existing rules folders)
await this.importRulesFiles(importMode, rulesFiles || [], source)
}
// Refresh the modes after import

View file

@ -28,6 +28,7 @@ export const providerProfilesSchema = z.object({
diffSettingsMigrated: z.boolean().optional(),
openAiHeadersMigrated: z.boolean().optional(),
consecutiveMistakeLimitMigrated: z.boolean().optional(),
todoListEnabledMigrated: z.boolean().optional(),
})
.optional(),
})
@ -51,6 +52,7 @@ export class ProviderSettingsManager {
diffSettingsMigrated: true, // Mark as migrated on fresh installs
openAiHeadersMigrated: true, // Mark as migrated on fresh installs
consecutiveMistakeLimitMigrated: true, // Mark as migrated on fresh installs
todoListEnabledMigrated: true, // Mark as migrated on fresh installs
},
}
@ -117,6 +119,7 @@ export class ProviderSettingsManager {
diffSettingsMigrated: false,
openAiHeadersMigrated: false,
consecutiveMistakeLimitMigrated: false,
todoListEnabledMigrated: false,
} // Initialize with default values
isDirty = true
}
@ -145,6 +148,12 @@ export class ProviderSettingsManager {
isDirty = true
}
if (!providerProfiles.migrations.todoListEnabledMigrated) {
await this.migrateTodoListEnabled(providerProfiles)
providerProfiles.migrations.todoListEnabledMigrated = true
isDirty = true
}
if (isDirty) {
await this.store(providerProfiles)
}
@ -250,6 +259,18 @@ export class ProviderSettingsManager {
}
}
private async migrateTodoListEnabled(providerProfiles: ProviderProfiles) {
try {
for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) {
if (apiConfig.todoListEnabled === undefined) {
apiConfig.todoListEnabled = true
}
}
} catch (error) {
console.error(`[MigrateTodoListEnabled] Failed to migrate todo list enabled setting:`, error)
}
}
/**
* List all available configs with metadata.
*/

View file

@ -1373,7 +1373,7 @@ describe("CustomModesManager", () => {
})
describe("exportModeWithRules", () => {
it("should return error when no workspace is available", async () => {
it("should return error when mode is not found and no workspace is available", async () => {
// Create a fresh manager instance to avoid cache issues
const freshManager = new CustomModesManager(mockContext, mockOnUpdate)
@ -1391,7 +1391,7 @@ describe("CustomModesManager", () => {
const result = await freshManager.exportModeWithRules("test-mode")
expect(result.success).toBe(false)
expect(result.error).toBe("No workspace found")
expect(result.error).toBe("Mode not found")
})
it("should return error when mode is not found", async () => {
@ -1571,5 +1571,133 @@ describe("CustomModesManager", () => {
expect(result.success).toBe(true)
expect(result.yaml).toContain("test-mode")
})
it("should successfully export global mode with rules from global .roo directory", async () => {
// Mock a global mode
const globalMode = {
slug: "global-test-mode",
name: "Global Test Mode",
roleDefinition: "Global Test Role",
groups: ["read"],
source: "global",
}
// Create a fresh manager instance to avoid cache issues
const freshManager = new CustomModesManager(mockContext, mockOnUpdate)
;(fs.readFile as Mock).mockImplementation(async (path: string) => {
if (path === mockSettingsPath) {
return yaml.stringify({ customModes: [globalMode] })
}
if (path.includes("rules-global-test-mode") && path.includes("rule1.md")) {
return "Global rule content"
}
throw new Error("File not found")
})
;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => {
return path === mockSettingsPath
})
;(fs.stat as Mock).mockImplementation(async (path: string) => {
if (path.includes("rules-global-test-mode")) {
return { isDirectory: () => true }
}
throw new Error("Directory not found")
})
;(fs.readdir as Mock).mockImplementation(async (path: string) => {
if (path.includes("rules-global-test-mode")) {
return [{ name: "rule1.md", isFile: () => true }]
}
return []
})
const result = await freshManager.exportModeWithRules("global-test-mode")
expect(result.success).toBe(true)
expect(result.yaml).toContain("global-test-mode")
expect(result.yaml).toContain("Global Test Mode")
expect(result.yaml).toContain("Global rule content")
})
it("should successfully export global mode without rules when global rules directory doesn't exist", async () => {
// Mock a global mode
const globalMode = {
slug: "global-test-mode",
name: "Global Test Mode",
roleDefinition: "Global Test Role",
groups: ["read"],
source: "global",
}
// Create a fresh manager instance to avoid cache issues
const freshManager = new CustomModesManager(mockContext, mockOnUpdate)
;(fs.readFile as Mock).mockImplementation(async (path: string) => {
if (path === mockSettingsPath) {
return yaml.stringify({ customModes: [globalMode] })
}
throw new Error("File not found")
})
;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => {
return path === mockSettingsPath
})
;(fs.stat as Mock).mockRejectedValue(new Error("Directory not found"))
const result = await freshManager.exportModeWithRules("global-test-mode")
expect(result.success).toBe(true)
expect(result.yaml).toContain("global-test-mode")
expect(result.yaml).toContain("Global Test Mode")
// Should not contain rulesFiles since no rules directory exists
expect(result.yaml).not.toContain("rulesFiles")
})
it("should handle global mode export when workspace is not available", async () => {
// Mock a global mode
const globalMode = {
slug: "global-test-mode",
name: "Global Test Mode",
roleDefinition: "Global Test Role",
groups: ["read"],
source: "global",
}
// Create a fresh manager instance to avoid cache issues
const freshManager = new CustomModesManager(mockContext, mockOnUpdate)
// Mock no workspace folders
;(vscode.workspace as any).workspaceFolders = []
;(getWorkspacePath as Mock).mockReturnValue(null)
;(fs.readFile as Mock).mockImplementation(async (path: string) => {
if (path === mockSettingsPath) {
return yaml.stringify({ customModes: [globalMode] })
}
if (path.includes("rules-global-test-mode") && path.includes("rule1.md")) {
return "Global rule content"
}
throw new Error("File not found")
})
;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => {
return path === mockSettingsPath
})
;(fs.stat as Mock).mockImplementation(async (path: string) => {
if (path.includes("rules-global-test-mode")) {
return { isDirectory: () => true }
}
throw new Error("Directory not found")
})
;(fs.readdir as Mock).mockImplementation(async (path: string) => {
if (path.includes("rules-global-test-mode")) {
return [{ name: "rule1.md", isFile: () => true }]
}
return []
})
const result = await freshManager.exportModeWithRules("global-test-mode")
// Should succeed even without workspace since it's a global mode
expect(result.success).toBe(true)
expect(result.yaml).toContain("global-test-mode")
expect(result.yaml).toContain("Global rule content")
})
})
})

View file

@ -67,6 +67,7 @@ describe("ProviderSettingsManager", () => {
diffSettingsMigrated: true,
openAiHeadersMigrated: true,
consecutiveMistakeLimitMigrated: true,
todoListEnabledMigrated: true,
},
}),
)
@ -186,6 +187,48 @@ describe("ProviderSettingsManager", () => {
expect(storedConfig.migrations.consecutiveMistakeLimitMigrated).toEqual(true)
})
it("should call migrateTodoListEnabled if it has not done so already", async () => {
mockSecrets.get.mockResolvedValue(
JSON.stringify({
currentApiConfigName: "default",
apiConfigs: {
default: {
config: {},
id: "default",
todoListEnabled: undefined,
},
test: {
apiProvider: "anthropic",
todoListEnabled: undefined,
},
existing: {
apiProvider: "anthropic",
// this should not really be possible, unless someone has loaded a hand edited config,
// but we don't overwrite so we'll check that
todoListEnabled: false,
},
},
migrations: {
rateLimitSecondsMigrated: true,
diffSettingsMigrated: true,
openAiHeadersMigrated: true,
consecutiveMistakeLimitMigrated: true,
todoListEnabledMigrated: false,
},
}),
)
await providerSettingsManager.initialize()
// Get the last call to store, which should contain the migrated config
const calls = mockSecrets.store.mock.calls
const storedConfig = JSON.parse(calls[calls.length - 1][1])
expect(storedConfig.apiConfigs.default.todoListEnabled).toEqual(true)
expect(storedConfig.apiConfigs.test.todoListEnabled).toEqual(true)
expect(storedConfig.apiConfigs.existing.todoListEnabled).toEqual(false)
expect(storedConfig.migrations.todoListEnabledMigrated).toEqual(true)
})
it("should throw error if secrets storage fails", async () => {
mockSecrets.get.mockRejectedValue(new Error("Storage failed"))

View file

@ -6,6 +6,7 @@ import pWaitFor from "p-wait-for"
import delay from "delay"
import type { ExperimentId } from "@roo-code/types"
import { DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types"
import { EXPERIMENT_IDS, experiments as Experiments } from "../../shared/experiments"
import { formatLanguage } from "../../shared/language"
@ -25,7 +26,11 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
const clineProvider = cline.providerRef.deref()
const state = await clineProvider?.getState()
const { terminalOutputLineLimit = 500, maxWorkspaceFiles = 200 } = state ?? {}
const {
terminalOutputLineLimit = 500,
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
maxWorkspaceFiles = 200,
} = state ?? {}
// It could be useful for cline to know if the user went from one or no
// file to another between messages, so we always include this context.
@ -111,7 +116,11 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
let newOutput = TerminalRegistry.getUnretrievedOutput(busyTerminal.id)
if (newOutput) {
newOutput = Terminal.compressTerminalOutput(newOutput, terminalOutputLineLimit)
newOutput = Terminal.compressTerminalOutput(
newOutput,
terminalOutputLineLimit,
terminalOutputCharacterLimit,
)
terminalDetails += `\n### New Output\n${newOutput}`
}
}
@ -139,7 +148,11 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
let output = process.getUnretrievedOutput()
if (output) {
output = Terminal.compressTerminalOutput(output, terminalOutputLineLimit)
output = Terminal.compressTerminalOutput(
output,
terminalOutputLineLimit,
terminalOutputCharacterLimit,
)
terminalOutputs.push(`Command: \`${process.command}\`\n${output}`)
}
}

View file

@ -555,6 +555,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -269,55 +269,6 @@ Examples:
<ignore_case>true</ignore_case>
</search_and_replace>
## use_mcp_tool
Description: Request to use a tool provided by a connected MCP server. Each MCP server can provide multiple tools with different capabilities. Tools have defined input schemas that specify required and optional parameters.
Parameters:
- server_name: (required) The name of the MCP server providing the tool
- tool_name: (required) The name of the tool to execute
- arguments: (required) A JSON object containing the tool's input parameters, following the tool's input schema
Usage:
<use_mcp_tool>
<server_name>server name here</server_name>
<tool_name>tool name here</tool_name>
<arguments>
{
"param1": "value1",
"param2": "value2"
}
</arguments>
</use_mcp_tool>
Example: Requesting to use an MCP tool
<use_mcp_tool>
<server_name>weather-server</server_name>
<tool_name>get_forecast</tool_name>
<arguments>
{
"city": "San Francisco",
"days": 5
}
</arguments>
</use_mcp_tool>
## access_mcp_resource
Description: Request to access a resource provided by a connected MCP server. Resources represent data sources that can be used as context, such as files, API responses, or system information.
Parameters:
- server_name: (required) The name of the MCP server providing the resource
- uri: (required) The URI identifying the specific resource to access
Usage:
<access_mcp_resource>
<server_name>server name here</server_name>
<uri>resource URI here</uri>
</access_mcp_resource>
Example: Requesting to access an MCP resource
<access_mcp_resource>
<server_name>weather-server</server_name>
<uri>weather://san-francisco/current</uri>
</access_mcp_resource>
## ask_followup_question
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
Parameters:
@ -508,18 +459,7 @@ It is crucial to proceed step-by-step, waiting for the user's message after each
By waiting for and carefully considering the user's response after each tool use, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.
MCP SERVERS
The Model Context Protocol (MCP) enables communication between the system and MCP servers that provide additional tools and resources to extend your capabilities. MCP servers can be one of two types:
1. Local (Stdio-based) servers: These run locally on the user's machine and communicate via standard input/output
2. Remote (SSE-based) servers: These run on remote machines and communicate via Server-Sent Events (SSE) over HTTP/HTTPS
# Connected MCP Servers
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
(No MCP servers currently connected)
====
@ -531,8 +471,6 @@ CAPABILITIES
- You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task.
- For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed.
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
====
@ -617,6 +555,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -519,7 +519,7 @@ The Model Context Protocol (MCP) enables communication between the system and MC
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
(No MCP servers currently connected)
## Creating an MCP Server
The user may ask you something along the lines of "add a tool" that does some function, in other words to create an MCP server that provides tools and resources that may connect to external APIs for example. If they do, you should obtain detailed instructions on this topic using the fetch_instructions tool, like this:
@ -623,6 +623,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -560,6 +560,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -555,6 +555,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -611,6 +611,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -555,6 +555,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -643,6 +643,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -555,6 +555,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -611,6 +611,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -519,7 +519,7 @@ The Model Context Protocol (MCP) enables communication between the system and MC
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
(No MCP servers currently connected)
## Creating an MCP Server
The user may ask you something along the lines of "add a tool" that does some function, in other words to create an MCP server that provides tools and resources that may connect to external APIs for example. If they do, you should obtain detailed instructions on this topic using the fetch_instructions tool, like this:
@ -623,6 +623,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -555,6 +555,8 @@ Mode-specific Instructions:
- Focused on a single, well-defined outcome
- Clear enough that another mode could execute it independently
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.

View file

@ -168,9 +168,9 @@ const mockContext = {
} as unknown as vscode.ExtensionContext
// Instead of extending McpHub, create a mock that implements just what we need
const createMockMcpHub = (): McpHub =>
const createMockMcpHub = (withServers: boolean = false): McpHub =>
({
getServers: () => [],
getServers: () => (withServers ? [{ name: "test-server", disabled: false }] : []),
getMcpServersPath: async () => "/mock/mcp/path",
getMcpSettingsFilePath: async () => "/mock/settings/path",
dispose: async () => {},
@ -236,7 +236,7 @@ describe("addCustomInstructions", () => {
})
it("should include MCP server creation info when enabled", async () => {
const mockMcpHub = createMockMcpHub()
const mockMcpHub = createMockMcpHub(true)
const prompt = await SYSTEM_PROMPT(
mockContext,
@ -262,7 +262,7 @@ describe("addCustomInstructions", () => {
})
it("should exclude MCP server creation info when disabled", async () => {
const mockMcpHub = createMockMcpHub()
const mockMcpHub = createMockMcpHub(false)
const prompt = await SYSTEM_PROMPT(
mockContext,

View file

@ -79,7 +79,7 @@ __setMockImplementation(
globalCustomInstructions: string,
cwd: string,
mode: string,
options?: { language?: string },
options?: { language?: string; rooIgnoreInstructions?: string; settings?: Record<string, any> },
) => {
const sections = []
@ -168,9 +168,9 @@ const mockContext = {
} as unknown as vscode.ExtensionContext
// Instead of extending McpHub, create a mock that implements just what we need
const createMockMcpHub = (): McpHub =>
const createMockMcpHub = (withServers: boolean = false): McpHub =>
({
getServers: () => [],
getServers: () => (withServers ? [{ name: "test-server", disabled: false }] : []),
getMcpServersPath: async () => "/mock/mcp/path",
getMcpSettingsFilePath: async () => "/mock/settings/path",
dispose: async () => {},
@ -250,7 +250,7 @@ describe("SYSTEM_PROMPT", () => {
})
it("should include MCP server info when mcpHub is provided", async () => {
mockMcpHub = createMockMcpHub()
mockMcpHub = createMockMcpHub(true)
const prompt = await SYSTEM_PROMPT(
mockContext,
@ -575,6 +575,94 @@ describe("SYSTEM_PROMPT", () => {
expect(prompt.indexOf(modes[0].roleDefinition)).toBeLessThan(prompt.indexOf("TOOL USE"))
})
it("should exclude update_todo_list tool when todoListEnabled is false", async () => {
const settings = {
todoListEnabled: false,
}
const prompt = await SYSTEM_PROMPT(
mockContext,
"/test/path",
false, // supportsComputerUse
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes
undefined, // globalCustomInstructions
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
undefined, // language
undefined, // rooIgnoreInstructions
undefined, // partialReadsEnabled
settings, // settings
)
// Should not contain the tool description
expect(prompt).not.toContain("## update_todo_list")
// Mode instructions will still reference the tool with a fallback to markdown
})
it("should include update_todo_list tool when todoListEnabled is true", async () => {
const settings = {
todoListEnabled: true,
}
const prompt = await SYSTEM_PROMPT(
mockContext,
"/test/path",
false, // supportsComputerUse
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes
undefined, // globalCustomInstructions
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
undefined, // language
undefined, // rooIgnoreInstructions
undefined, // partialReadsEnabled
settings, // settings
)
expect(prompt).toContain("update_todo_list")
expect(prompt).toContain("## update_todo_list")
})
it("should include update_todo_list tool when todoListEnabled is undefined", async () => {
const settings = {
// todoListEnabled not set
}
const prompt = await SYSTEM_PROMPT(
mockContext,
"/test/path",
false, // supportsComputerUse
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes
undefined, // globalCustomInstructions
undefined, // diffEnabled
experiments,
true, // enableMcpServerCreation
undefined, // language
undefined, // rooIgnoreInstructions
undefined, // partialReadsEnabled
settings, // settings
)
expect(prompt).toContain("update_todo_list")
expect(prompt).toContain("## update_todo_list")
})
afterAll(() => {
vi.restoreAllMocks()
})

View file

@ -1033,6 +1033,157 @@ describe("Rules directory reading", () => {
expect(result).toContain("content of file3")
})
it("should return files in alphabetical order by filename", async () => {
// Simulate .roo/rules directory exists
statMock.mockResolvedValueOnce({
isDirectory: vi.fn().mockReturnValue(true),
} as any)
// Simulate listing files in non-alphabetical order to test sorting
readdirMock.mockResolvedValueOnce([
{ name: "zebra.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules" },
{ name: "alpha.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules" },
{ name: "Beta.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules" }, // Test case-insensitive sorting
] as any)
statMock.mockImplementation((path) => {
return Promise.resolve({
isFile: vi.fn().mockReturnValue(true),
}) as any
})
readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
const normalizedPath = pathStr.replace(/\\/g, "/")
if (normalizedPath === "/fake/path/.roo/rules/zebra.txt") {
return Promise.resolve("zebra content")
}
if (normalizedPath === "/fake/path/.roo/rules/alpha.txt") {
return Promise.resolve("alpha content")
}
if (normalizedPath === "/fake/path/.roo/rules/Beta.txt") {
return Promise.resolve("beta content")
}
return Promise.reject({ code: "ENOENT" })
})
const result = await loadRuleFiles("/fake/path")
// Files should appear in alphabetical order: alpha.txt, Beta.txt, zebra.txt
const alphaIndex = result.indexOf("alpha content")
const betaIndex = result.indexOf("beta content")
const zebraIndex = result.indexOf("zebra content")
expect(alphaIndex).toBeLessThan(betaIndex)
expect(betaIndex).toBeLessThan(zebraIndex)
// Verify the expected file paths are in the result
const expectedAlphaPath =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\alpha.txt" : "/fake/path/.roo/rules/alpha.txt"
const expectedBetaPath =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\Beta.txt" : "/fake/path/.roo/rules/Beta.txt"
const expectedZebraPath =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\zebra.txt" : "/fake/path/.roo/rules/zebra.txt"
expect(result).toContain(`# Rules from ${expectedAlphaPath}:`)
expect(result).toContain(`# Rules from ${expectedBetaPath}:`)
expect(result).toContain(`# Rules from ${expectedZebraPath}:`)
})
it("should sort symlinks by their symlink names, not target names", async () => {
// Reset mocks
statMock.mockReset()
readdirMock.mockReset()
readlinkMock.mockReset()
readFileMock.mockReset()
// First call: check if .roo/rules directory exists
statMock.mockResolvedValueOnce({
isDirectory: vi.fn().mockReturnValue(true),
} as any)
// Simulate listing files with symlinks that point to files with different names
readdirMock.mockResolvedValueOnce([
{
name: "01-first.link",
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/fake/path/.roo/rules",
},
{
name: "02-second.link",
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/fake/path/.roo/rules",
},
{
name: "03-third.link",
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/fake/path/.roo/rules",
},
] as any)
// Mock readlink to return target paths that would sort differently than symlink names
readlinkMock
.mockResolvedValueOnce("../../targets/zzz-last.txt") // 01-first.link -> zzz-last.txt
.mockResolvedValueOnce("../../targets/aaa-first.txt") // 02-second.link -> aaa-first.txt
.mockResolvedValueOnce("../../targets/mmm-middle.txt") // 03-third.link -> mmm-middle.txt
// Set up stat mock for the remaining calls
statMock.mockImplementation((path) => {
const normalizedPath = path.toString().replace(/\\/g, "/")
// Target files exist and are files
if (normalizedPath.endsWith(".txt")) {
return Promise.resolve({
isFile: vi.fn().mockReturnValue(true),
isDirectory: vi.fn().mockReturnValue(false),
} as any)
}
return Promise.resolve({
isFile: vi.fn().mockReturnValue(false),
isDirectory: vi.fn().mockReturnValue(false),
} as any)
})
readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
const normalizedPath = pathStr.replace(/\\/g, "/")
if (normalizedPath.endsWith("zzz-last.txt")) {
return Promise.resolve("content from zzz-last.txt")
}
if (normalizedPath.endsWith("aaa-first.txt")) {
return Promise.resolve("content from aaa-first.txt")
}
if (normalizedPath.endsWith("mmm-middle.txt")) {
return Promise.resolve("content from mmm-middle.txt")
}
return Promise.reject({ code: "ENOENT" })
})
const result = await loadRuleFiles("/fake/path")
// Content should appear in order of symlink names (01-first, 02-second, 03-third)
// NOT in order of target names (aaa-first, mmm-middle, zzz-last)
const firstIndex = result.indexOf("content from zzz-last.txt") // from 01-first.link
const secondIndex = result.indexOf("content from aaa-first.txt") // from 02-second.link
const thirdIndex = result.indexOf("content from mmm-middle.txt") // from 03-third.link
// All content should be found
expect(firstIndex).toBeGreaterThan(-1)
expect(secondIndex).toBeGreaterThan(-1)
expect(thirdIndex).toBeGreaterThan(-1)
// And they should be in the order of symlink names, not target names
expect(firstIndex).toBeLessThan(secondIndex)
expect(secondIndex).toBeLessThan(thirdIndex)
// Verify the target paths are shown (not symlink paths)
expect(result).toContain("zzz-last.txt")
expect(result).toContain("aaa-first.txt")
expect(result).toContain("mmm-middle.txt")
})
it("should handle empty file list gracefully", async () => {
// Simulate .roo/rules directory exists
statMock.mockResolvedValueOnce({

View file

@ -44,7 +44,7 @@ const MAX_DEPTH = 5
async function resolveDirectoryEntry(
entry: Dirent,
dirPath: string,
filePaths: string[],
fileInfo: Array<{ originalPath: string; resolvedPath: string }>,
depth: number,
): Promise<void> {
// Avoid cyclic symlinks
@ -54,44 +54,49 @@ async function resolveDirectoryEntry(
const fullPath = path.resolve(entry.parentPath || dirPath, entry.name)
if (entry.isFile()) {
// Regular file
filePaths.push(fullPath)
// Regular file - both original and resolved paths are the same
fileInfo.push({ originalPath: fullPath, resolvedPath: fullPath })
} else if (entry.isSymbolicLink()) {
// Await the resolution of the symbolic link
await resolveSymLink(fullPath, filePaths, depth + 1)
await resolveSymLink(fullPath, fileInfo, depth + 1)
}
}
/**
* Recursively resolve a symbolic link and collect file paths
*/
async function resolveSymLink(fullPath: string, filePaths: string[], depth: number): Promise<void> {
async function resolveSymLink(
symlinkPath: string,
fileInfo: Array<{ originalPath: string; resolvedPath: string }>,
depth: number,
): Promise<void> {
// Avoid cyclic symlinks
if (depth > MAX_DEPTH) {
return
}
try {
// Get the symlink target
const linkTarget = await fs.readlink(fullPath)
const linkTarget = await fs.readlink(symlinkPath)
// Resolve the target path (relative to the symlink location)
const resolvedTarget = path.resolve(path.dirname(fullPath), linkTarget)
const resolvedTarget = path.resolve(path.dirname(symlinkPath), linkTarget)
// Check if the target is a file
const stats = await fs.stat(resolvedTarget)
if (stats.isFile()) {
filePaths.push(resolvedTarget)
// For symlinks to files, store the symlink path as original and target as resolved
fileInfo.push({ originalPath: symlinkPath, resolvedPath: resolvedTarget })
} else if (stats.isDirectory()) {
const anotherEntries = await fs.readdir(resolvedTarget, { withFileTypes: true, recursive: true })
// Collect promises for recursive calls within the directory
const directoryPromises: Promise<void>[] = []
for (const anotherEntry of anotherEntries) {
directoryPromises.push(resolveDirectoryEntry(anotherEntry, resolvedTarget, filePaths, depth + 1))
directoryPromises.push(resolveDirectoryEntry(anotherEntry, resolvedTarget, fileInfo, depth + 1))
}
// Wait for all entries in the resolved directory to be processed
await Promise.all(directoryPromises)
} else if (stats.isSymbolicLink()) {
// Handle nested symlinks by awaiting the recursive call
await resolveSymLink(resolvedTarget, filePaths, depth + 1)
await resolveSymLink(resolvedTarget, fileInfo, depth + 1)
}
} catch (err) {
// Skip invalid symlinks
@ -106,29 +111,31 @@ async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ file
const entries = await fs.readdir(dirPath, { withFileTypes: true, recursive: true })
// Process all entries - regular files and symlinks that might point to files
const filePaths: string[] = []
// Store both original path (for sorting) and resolved path (for reading)
const fileInfo: Array<{ originalPath: string; resolvedPath: string }> = []
// Collect promises for the initial resolution calls
const initialPromises: Promise<void>[] = []
for (const entry of entries) {
initialPromises.push(resolveDirectoryEntry(entry, dirPath, filePaths, 0))
initialPromises.push(resolveDirectoryEntry(entry, dirPath, fileInfo, 0))
}
// Wait for all asynchronous operations (including recursive ones) to complete
await Promise.all(initialPromises)
const fileContents = await Promise.all(
filePaths.map(async (file) => {
fileInfo.map(async ({ originalPath, resolvedPath }) => {
try {
// Check if it's a file (not a directory)
const stats = await fs.stat(file)
const stats = await fs.stat(resolvedPath)
if (stats.isFile()) {
// Filter out cache files and system files that shouldn't be in rules
if (!shouldIncludeRuleFile(file)) {
if (!shouldIncludeRuleFile(resolvedPath)) {
return null
}
const content = await safeReadFile(file)
return { filename: file, content }
const content = await safeReadFile(resolvedPath)
// Use resolvedPath for display to maintain existing behavior
return { filename: resolvedPath, content, sortKey: originalPath }
}
return null
} catch (err) {
@ -138,7 +145,19 @@ async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ file
)
// Filter out null values (directories, failed reads, or excluded files)
return fileContents.filter((item): item is { filename: string; content: string } => item !== null)
const filteredFiles = fileContents.filter(
(item): item is { filename: string; content: string; sortKey: string } => item !== null,
)
// Sort files alphabetically by the original filename (case-insensitive) to ensure consistent order
// For symlinks, this will use the symlink name, not the target name
return filteredFiles
.sort((a, b) => {
const filenameA = path.basename(a.sortKey).toLowerCase()
const filenameB = path.basename(b.sortKey).toLowerCase()
return filenameA.localeCompare(filenameB)
})
.map(({ filename, content }) => ({ filename, content }))
} catch (err) {
return []
}
@ -200,7 +219,7 @@ export async function addCustomInstructions(
globalCustomInstructions: string,
cwd: string,
mode: string,
options: { language?: string; rooIgnoreInstructions?: string } = {},
options: { language?: string; rooIgnoreInstructions?: string; settings?: Record<string, any> } = {},
): Promise<string> {
const sections = []

View file

@ -71,9 +71,14 @@ async function generatePrompt(
const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0]
const { roleDefinition, baseInstructions } = getModeSelection(mode, promptComponent, customModeConfigs)
// Check if MCP functionality should be included
const hasMcpGroup = modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp")
const hasMcpServers = mcpHub && mcpHub.getServers().length > 0
const shouldIncludeMcp = hasMcpGroup && hasMcpServers
const [modesSection, mcpServersSection] = await Promise.all([
getModesSection(context),
modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp")
shouldIncludeMcp
? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation)
: Promise.resolve(""),
])
@ -93,7 +98,7 @@ ${getToolDescriptionsForMode(
codeIndexManager,
effectiveDiffStrategy,
browserViewportSize,
mcpHub,
shouldIncludeMcp ? mcpHub : undefined,
customModeConfigs,
experiments,
partialReadsEnabled,
@ -104,7 +109,7 @@ ${getToolUseGuidelinesSection(codeIndexManager)}
${mcpServersSection}
${getCapabilitiesSection(cwd, supportsComputerUse, mcpHub, effectiveDiffStrategy, codeIndexManager)}
${getCapabilitiesSection(cwd, supportsComputerUse, shouldIncludeMcp ? mcpHub : undefined, effectiveDiffStrategy, codeIndexManager)}
${modesSection}
@ -114,7 +119,7 @@ ${getSystemInfoSection(cwd)}
${getObjectiveSection(codeIndexManager, experiments)}
${await addCustomInstructions(baseInstructions, globalCustomInstructions || "", cwd, mode, { language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions })}`
${await addCustomInstructions(baseInstructions, globalCustomInstructions || "", cwd, mode, { language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions, settings })}`
return basePrompt
}
@ -172,7 +177,7 @@ export const SYSTEM_PROMPT = async (
globalCustomInstructions || "",
cwd,
mode,
{ language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions },
{ language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions, settings },
)
// For file-based prompts, don't include the tool sections

View file

@ -109,6 +109,11 @@ export function getToolDescriptionsForMode(
tools.delete("codebase_search")
}
// Conditionally exclude update_todo_list if disabled in settings
if (settings?.todoListEnabled === false) {
tools.delete("update_todo_list")
}
// Map tool descriptions for allowed tools
const descriptions = Array.from(tools).map((toolName) => {
const descriptionFn = toolDescriptionMap[toolName]

View file

@ -1103,9 +1103,9 @@ describe("Sliding Window", () => {
expect(result2.prevContextTokens).toBe(50001)
})
it("should use 20% of context window as buffer when maxTokens is undefined", async () => {
it("should use ANTHROPIC_DEFAULT_MAX_TOKENS as buffer when maxTokens is undefined", async () => {
const modelInfo = createModelInfo(100000, undefined)
// Max tokens = 100000 - (100000 * 0.2) = 80000
// Max tokens = 100000 - ANTHROPIC_DEFAULT_MAX_TOKENS = 100000 - 8192 = 91808
// Create messages with very small content in the last one to avoid token overflow
const messagesWithSmallContent = [
@ -1117,7 +1117,7 @@ describe("Sliding Window", () => {
// Below max tokens and buffer - no truncation
const result1 = await truncateConversationIfNeeded({
messages: messagesWithSmallContent,
totalTokens: 69999, // Well below threshold + dynamic buffer
totalTokens: 81807, // Well below threshold + dynamic buffer (91808 - 10000 = 81808)
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
apiHandler: mockApiHandler,
@ -1132,13 +1132,13 @@ describe("Sliding Window", () => {
messages: messagesWithSmallContent,
summary: "",
cost: 0,
prevContextTokens: 69999,
prevContextTokens: 81807,
})
// Above max tokens - truncate
const result2 = await truncateConversationIfNeeded({
messages: messagesWithSmallContent,
totalTokens: 80001, // Above threshold
totalTokens: 81809, // Above threshold (81808)
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
apiHandler: mockApiHandler,
@ -1153,7 +1153,7 @@ describe("Sliding Window", () => {
expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
expect(result2.summary).toBe("")
expect(result2.cost).toBe(0)
expect(result2.prevContextTokens).toBe(80001)
expect(result2.prevContextTokens).toBe(81809)
})
it("should handle small context windows appropriately", async () => {

View file

@ -5,6 +5,7 @@ import { TelemetryService } from "@roo-code/telemetry"
import { ApiHandler } from "../../api"
import { MAX_CONDENSE_THRESHOLD, MIN_CONDENSE_THRESHOLD, summarizeConversation, SummarizeResponse } from "../condense"
import { ApiMessage } from "../task-persistence/apiMessages"
import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types"
/**
* Default percentage of the context window to use as a buffer when deciding when to truncate
@ -105,7 +106,7 @@ export async function truncateConversationIfNeeded({
let error: string | undefined
let cost = 0
// Calculate the maximum tokens reserved for response
const reservedTokens = maxTokens || contextWindow * 0.2
const reservedTokens = maxTokens || ANTHROPIC_DEFAULT_MAX_TOKENS
// Estimate tokens for the last message (which is always a user message)
const lastMessage = messages[messages.length - 1]

View file

@ -1473,16 +1473,18 @@ export class Task extends EventEmitter<ClineEvents> {
// could be in (i.e. could have streamed some tools the user
// may have executed), so we just resort to replicating a
// cancel task.
this.abortTask()
// Check if this was a user-initiated cancellation
// If this.abort is true, it means the user clicked cancel, so we should
// Check if this was a user-initiated cancellation BEFORE calling abortTask
// If this.abort is already true, it means the user clicked cancel, so we should
// treat this as "user_cancelled" rather than "streaming_failed"
const cancelReason = this.abort ? "user_cancelled" : "streaming_failed"
const streamingFailedMessage = this.abort
? undefined
: (error.message ?? JSON.stringify(serializeError(error), null, 2))
// Now call abortTask after determining the cancel reason
await this.abortTask()
await abortStream(cancelReason, streamingFailedMessage)
const history = await provider?.getTaskWithId(this.taskId)

View file

@ -4,7 +4,7 @@ import * as vscode from "vscode"
import delay from "delay"
import { CommandExecutionStatus } from "@roo-code/types"
import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { Task } from "../task/Task"
@ -63,7 +63,11 @@ export async function executeCommandTool(
const executionId = cline.lastMessageTs?.toString() ?? Date.now().toString()
const clineProvider = await cline.providerRef.deref()
const clineProviderState = await clineProvider?.getState()
const { terminalOutputLineLimit = 500, terminalShellIntegrationDisabled = false } = clineProviderState ?? {}
const {
terminalOutputLineLimit = 500,
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
terminalShellIntegrationDisabled = false,
} = clineProviderState ?? {}
// Get command execution timeout from VSCode configuration (in seconds)
const commandExecutionTimeoutSeconds = vscode.workspace
@ -87,6 +91,7 @@ export async function executeCommandTool(
customCwd,
terminalShellIntegrationDisabled,
terminalOutputLineLimit,
terminalOutputCharacterLimit,
commandExecutionTimeout,
}
@ -133,6 +138,7 @@ export type ExecuteCommandOptions = {
customCwd?: string
terminalShellIntegrationDisabled?: boolean
terminalOutputLineLimit?: number
terminalOutputCharacterLimit?: number
commandExecutionTimeout?: number
}
@ -144,6 +150,7 @@ export async function executeCommand(
customCwd,
terminalShellIntegrationDisabled = false,
terminalOutputLineLimit = 500,
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
commandExecutionTimeout = 0,
}: ExecuteCommandOptions,
): Promise<[boolean, ToolResponse]> {
@ -179,7 +186,11 @@ export async function executeCommand(
const callbacks: RooTerminalCallbacks = {
onLine: async (lines: string, process: RooTerminalProcess) => {
accumulatedOutput += lines
const compressedOutput = Terminal.compressTerminalOutput(accumulatedOutput, terminalOutputLineLimit)
const compressedOutput = Terminal.compressTerminalOutput(
accumulatedOutput,
terminalOutputLineLimit,
terminalOutputCharacterLimit,
)
const status: CommandExecutionStatus = { executionId, status: "output", output: compressedOutput }
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
@ -198,7 +209,11 @@ export async function executeCommand(
} catch (_error) {}
},
onCompleted: (output: string | undefined) => {
result = Terminal.compressTerminalOutput(output ?? "", terminalOutputLineLimit)
result = Terminal.compressTerminalOutput(
output ?? "",
terminalOutputLineLimit,
terminalOutputCharacterLimit,
)
cline.say("command_output", result)
completed = true
},

View file

@ -28,6 +28,7 @@ import {
openRouterDefaultModelId,
glamaDefaultModelId,
ORGANIZATION_ALLOW_ALL,
DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud"
@ -1526,6 +1527,7 @@ export class ClineProvider
cachedChromeHostUrl,
writeDelayMs,
terminalOutputLineLimit,
terminalOutputCharacterLimit,
terminalShellIntegrationTimeout,
terminalShellIntegrationDisabled,
terminalCommandDelay,
@ -1626,6 +1628,7 @@ export class ClineProvider
cachedChromeHostUrl: cachedChromeHostUrl,
writeDelayMs: writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
terminalOutputCharacterLimit: terminalOutputCharacterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? false,
terminalCommandDelay: terminalCommandDelay ?? 0,
@ -1795,6 +1798,8 @@ export class ClineProvider
fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
terminalOutputLineLimit: stateValues.terminalOutputLineLimit ?? 500,
terminalOutputCharacterLimit:
stateValues.terminalOutputCharacterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
terminalShellIntegrationTimeout:
stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? false,

View file

@ -1221,8 +1221,29 @@ export const webviewMessageHandler = async (
await provider.postStateToWebview()
break
case "terminalOutputLineLimit":
await updateGlobalState("terminalOutputLineLimit", message.value)
await provider.postStateToWebview()
// Validate that the line limit is a positive number
const lineLimit = message.value
if (typeof lineLimit === "number" && lineLimit > 0) {
await updateGlobalState("terminalOutputLineLimit", lineLimit)
await provider.postStateToWebview()
} else {
vscode.window.showErrorMessage(
t("common:errors.invalid_line_limit") || "Terminal output line limit must be a positive number",
)
}
break
case "terminalOutputCharacterLimit":
// Validate that the character limit is a positive number
const charLimit = message.value
if (typeof charLimit === "number" && charLimit > 0) {
await updateGlobalState("terminalOutputCharacterLimit", charLimit)
await provider.postStateToWebview()
} else {
vscode.window.showErrorMessage(
t("common:errors.invalid_character_limit") ||
"Terminal output character limit must be a positive number",
)
}
break
case "terminalShellIntegrationTimeout":
await updateGlobalState("terminalShellIntegrationTimeout", message.value)
@ -1433,6 +1454,11 @@ export const webviewMessageHandler = async (
await provider.postStateToWebview()
break
case "updateCondensingPrompt":
// Store the condensing prompt in customSupportPrompts["CONDENSE"] instead of customCondensingPrompt
const currentSupportPrompts = getGlobalState("customSupportPrompts") ?? {}
const updatedSupportPrompts = { ...currentSupportPrompts, CONDENSE: message.text }
await updateGlobalState("customSupportPrompts", updatedSupportPrompts)
// Also update the old field for backward compatibility during migration
await updateGlobalState("customCondensingPrompt", message.text)
await provider.postStateToWebview()
break
@ -2147,6 +2173,12 @@ export const webviewMessageHandler = async (
settings.codebaseIndexGeminiApiKey,
)
}
if (settings.codebaseIndexMistralApiKey !== undefined) {
await provider.contextProxy.storeSecret(
"codebaseIndexMistralApiKey",
settings.codebaseIndexMistralApiKey,
)
}
// Send success response first - settings are saved regardless of validation
await provider.postMessageToWebview({
@ -2239,6 +2271,7 @@ export const webviewMessageHandler = async (
"codebaseIndexOpenAiCompatibleApiKey",
))
const hasGeminiApiKey = !!(await provider.context.secrets.get("codebaseIndexGeminiApiKey"))
const hasMistralApiKey = !!(await provider.context.secrets.get("codebaseIndexMistralApiKey"))
provider.postMessageToWebview({
type: "codeIndexSecretStatus",
@ -2247,6 +2280,7 @@ export const webviewMessageHandler = async (
hasQdrantApiKey,
hasOpenAiCompatibleApiKey,
hasGeminiApiKey,
hasMistralApiKey,
},
})
break

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Falta la configuració d'Ollama per crear l'embedder",
"openAiCompatibleConfigMissing": "Falta la configuració compatible amb OpenAI per crear l'embedder",
"geminiConfigMissing": "Falta la configuració de Gemini per crear l'embedder",
"mistralConfigMissing": "Falta la configuració de Mistral per crear l'embedder",
"invalidEmbedderType": "Tipus d'embedder configurat no vàlid: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "No s'ha pogut determinar la dimensió del vector per al model '{{modelId}}' amb el proveïdor '{{provider}}'. Assegura't que la 'Dimensió d'incrustació' estigui configurada correctament als paràmetres del proveïdor compatible amb OpenAI.",
"vectorDimensionNotDetermined": "No s'ha pogut determinar la dimensió del vector per al model '{{modelId}}' amb el proveïdor '{{provider}}'. Comprova els perfils del model o la configuració.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Ollama-Konfiguration fehlt für die Erstellung des Embedders",
"openAiCompatibleConfigMissing": "OpenAI-kompatible Konfiguration fehlt für die Erstellung des Embedders",
"geminiConfigMissing": "Gemini-Konfiguration fehlt für die Erstellung des Embedders",
"mistralConfigMissing": "Mistral-Konfiguration fehlt für die Erstellung des Embedders",
"invalidEmbedderType": "Ungültiger Embedder-Typ konfiguriert: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "Konnte die Vektordimension für Modell '{{modelId}}' mit Anbieter '{{provider}}' nicht bestimmen. Stelle sicher, dass die 'Embedding-Dimension' in den OpenAI-kompatiblen Anbietereinstellungen korrekt eingestellt ist.",
"vectorDimensionNotDetermined": "Konnte die Vektordimension für Modell '{{modelId}}' mit Anbieter '{{provider}}' nicht bestimmen. Überprüfe die Modellprofile oder Konfiguration.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Ollama configuration missing for embedder creation",
"openAiCompatibleConfigMissing": "OpenAI Compatible configuration missing for embedder creation",
"geminiConfigMissing": "Gemini configuration missing for embedder creation",
"mistralConfigMissing": "Mistral configuration missing for embedder creation",
"invalidEmbedderType": "Invalid embedder type configured: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "Could not determine vector dimension for model '{{modelId}}' with provider '{{provider}}'. Please ensure the 'Embedding Dimension' is correctly set in the OpenAI-Compatible provider settings.",
"vectorDimensionNotDetermined": "Could not determine vector dimension for model '{{modelId}}' with provider '{{provider}}'. Check model profiles or configuration.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Falta la configuración de Ollama para crear el incrustador",
"openAiCompatibleConfigMissing": "Falta la configuración compatible con OpenAI para crear el incrustador",
"geminiConfigMissing": "Falta la configuración de Gemini para crear el incrustador",
"mistralConfigMissing": "Falta la configuración de Mistral para la creación del incrustador",
"invalidEmbedderType": "Tipo de incrustador configurado inválido: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "No se pudo determinar la dimensión del vector para el modelo '{{modelId}}' con el proveedor '{{provider}}'. Asegúrate de que la 'Dimensión de incrustación' esté configurada correctamente en los ajustes del proveedor compatible con OpenAI.",
"vectorDimensionNotDetermined": "No se pudo determinar la dimensión del vector para el modelo '{{modelId}}' con el proveedor '{{provider}}'. Verifica los perfiles del modelo o la configuración.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Configuration Ollama manquante pour la création de l'embedder",
"openAiCompatibleConfigMissing": "Configuration compatible OpenAI manquante pour la création de l'embedder",
"geminiConfigMissing": "Configuration Gemini manquante pour la création de l'embedder",
"mistralConfigMissing": "Configuration Mistral manquante pour la création de l'embedder",
"invalidEmbedderType": "Type d'embedder configuré invalide : {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "Impossible de déterminer la dimension du vecteur pour le modèle '{{modelId}}' avec le fournisseur '{{provider}}'. Assure-toi que la 'Dimension d'embedding' est correctement définie dans les paramètres du fournisseur compatible OpenAI.",
"vectorDimensionNotDetermined": "Impossible de déterminer la dimension du vecteur pour le modèle '{{modelId}}' avec le fournisseur '{{provider}}'. Vérifie les profils du modèle ou la configuration.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "एम्बेडर बनाने के लिए Ollama कॉन्फ़िगरेशन गायब है",
"openAiCompatibleConfigMissing": "एम्बेडर बनाने के लिए OpenAI संगत कॉन्फ़िगरेशन गायब है",
"geminiConfigMissing": "एम्बेडर बनाने के लिए Gemini कॉन्फ़िगरेशन गायब है",
"mistralConfigMissing": "एम्बेडर निर्माण के लिए मिस्ट्रल कॉन्फ़िगरेशन गायब है",
"invalidEmbedderType": "अमान्य एम्बेडर प्रकार कॉन्फ़िगर किया गया: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "प्रदाता '{{provider}}' के साथ मॉडल '{{modelId}}' के लिए वेक्टर आयाम निर्धारित नहीं कर सका। कृपया सुनिश्चित करें कि OpenAI-संगत प्रदाता सेटिंग्स में 'एम्बेडिंग आयाम' सही तरीके से सेट है।",
"vectorDimensionNotDetermined": "प्रदाता '{{provider}}' के साथ मॉडल '{{modelId}}' के लिए वेक्टर आयाम निर्धारित नहीं कर सका। मॉडल प्रोफ़ाइल या कॉन्फ़िगरेशन की जांच करें।",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Konfigurasi Ollama tidak ada untuk membuat embedder",
"openAiCompatibleConfigMissing": "Konfigurasi yang kompatibel dengan OpenAI tidak ada untuk membuat embedder",
"geminiConfigMissing": "Konfigurasi Gemini tidak ada untuk membuat embedder",
"mistralConfigMissing": "Konfigurasi Mistral hilang untuk pembuatan embedder",
"invalidEmbedderType": "Tipe embedder yang dikonfigurasi tidak valid: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "Tidak dapat menentukan dimensi vektor untuk model '{{modelId}}' dengan penyedia '{{provider}}'. Pastikan 'Dimensi Embedding' diatur dengan benar di pengaturan penyedia yang kompatibel dengan OpenAI.",
"vectorDimensionNotDetermined": "Tidak dapat menentukan dimensi vektor untuk model '{{modelId}}' dengan penyedia '{{provider}}'. Periksa profil model atau konfigurasi.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Configurazione Ollama mancante per la creazione dell'embedder",
"openAiCompatibleConfigMissing": "Configurazione compatibile con OpenAI mancante per la creazione dell'embedder",
"geminiConfigMissing": "Configurazione Gemini mancante per la creazione dell'embedder",
"mistralConfigMissing": "Configurazione di Mistral mancante per la creazione dell'embedder",
"invalidEmbedderType": "Tipo di embedder configurato non valido: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "Impossibile determinare la dimensione del vettore per il modello '{{modelId}}' con il provider '{{provider}}'. Assicurati che la 'Dimensione di embedding' sia impostata correttamente nelle impostazioni del provider compatibile con OpenAI.",
"vectorDimensionNotDetermined": "Impossibile determinare la dimensione del vettore per il modello '{{modelId}}' con il provider '{{provider}}'. Controlla i profili del modello o la configurazione.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "エンベッダー作成のためのOllama設定がありません",
"openAiCompatibleConfigMissing": "エンベッダー作成のためのOpenAI互換設定がありません",
"geminiConfigMissing": "エンベッダー作成のためのGemini設定がありません",
"mistralConfigMissing": "エンベッダー作成のためのMistral設定がありません",
"invalidEmbedderType": "無効なエンベッダータイプが設定されています: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "プロバイダー '{{provider}}' のモデル '{{modelId}}' の埋め込み次元を決定できませんでした。OpenAI互換プロバイダー設定で「埋め込み次元」が正しく設定されていることを確認してください。",
"vectorDimensionNotDetermined": "プロバイダー '{{provider}}' のモデル '{{modelId}}' の埋め込み次元を決定できませんでした。モデルプロファイルまたは設定を確認してください。",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "임베더 생성을 위한 Ollama 구성이 누락되었습니다",
"openAiCompatibleConfigMissing": "임베더 생성을 위한 OpenAI 호환 구성이 누락되었습니다",
"geminiConfigMissing": "임베더 생성을 위한 Gemini 구성이 누락되었습니다",
"mistralConfigMissing": "임베더 생성을 위한 Mistral 구성이 없습니다",
"invalidEmbedderType": "잘못된 임베더 유형이 구성되었습니다: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "프로바이더 '{{provider}}'의 모델 '{{modelId}}'에 대한 벡터 차원을 결정할 수 없습니다. OpenAI 호환 프로바이더 설정에서 '임베딩 차원'이 올바르게 설정되어 있는지 확인하세요.",
"vectorDimensionNotDetermined": "프로바이더 '{{provider}}'의 모델 '{{modelId}}'에 대한 벡터 차원을 결정할 수 없습니다. 모델 프로필 또는 구성을 확인하세요.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Ollama-configuratie ontbreekt voor het maken van embedder",
"openAiCompatibleConfigMissing": "OpenAI-compatibele configuratie ontbreekt voor het maken van embedder",
"geminiConfigMissing": "Gemini-configuratie ontbreekt voor het maken van embedder",
"mistralConfigMissing": "Mistral-configuratie ontbreekt voor het maken van de embedder",
"invalidEmbedderType": "Ongeldig embedder-type geconfigureerd: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "Kan de vectordimensie voor model '{{modelId}}' met provider '{{provider}}' niet bepalen. Zorg ervoor dat de 'Embedding Dimensie' correct is ingesteld in de OpenAI-compatibele provider-instellingen.",
"vectorDimensionNotDetermined": "Kan de vectordimensie voor model '{{modelId}}' met provider '{{provider}}' niet bepalen. Controleer modelprofielen of configuratie.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Brak konfiguracji Ollama do utworzenia embeddera",
"openAiCompatibleConfigMissing": "Brak konfiguracji kompatybilnej z OpenAI do utworzenia embeddera",
"geminiConfigMissing": "Brak konfiguracji Gemini do utworzenia embeddera",
"mistralConfigMissing": "Brak konfiguracji Mistral do utworzenia embeddera",
"invalidEmbedderType": "Skonfigurowano nieprawidłowy typ embeddera: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "Nie można określić wymiaru wektora dla modelu '{{modelId}}' z dostawcą '{{provider}}'. Upewnij się, że 'Wymiar osadzania' jest poprawnie ustawiony w ustawieniach dostawcy kompatybilnego z OpenAI.",
"vectorDimensionNotDetermined": "Nie można określić wymiaru wektora dla modelu '{{modelId}}' z dostawcą '{{provider}}'. Sprawdź profile modelu lub konfigurację.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Configuração do Ollama ausente para criação do embedder",
"openAiCompatibleConfigMissing": "Configuração compatível com OpenAI ausente para criação do embedder",
"geminiConfigMissing": "Configuração do Gemini ausente para criação do embedder",
"mistralConfigMissing": "Configuração do Mistral ausente para a criação do embedder",
"invalidEmbedderType": "Tipo de embedder configurado inválido: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "Não foi possível determinar a dimensão do vetor para o modelo '{{modelId}}' com o provedor '{{provider}}'. Certifique-se de que a 'Dimensão de Embedding' esteja configurada corretamente nas configurações do provedor compatível com OpenAI.",
"vectorDimensionNotDetermined": "Não foi possível determinar a dimensão do vetor para o modelo '{{modelId}}' com o provedor '{{provider}}'. Verifique os perfis do modelo ou a configuração.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Отсутствует конфигурация Ollama для создания эмбеддера",
"openAiCompatibleConfigMissing": "Отсутствует конфигурация, совместимая с OpenAI, для создания эмбеддера",
"geminiConfigMissing": "Отсутствует конфигурация Gemini для создания эмбеддера",
"mistralConfigMissing": "Конфигурация Mistral отсутствует для создания эмбеддера",
"invalidEmbedderType": "Настроен недопустимый тип эмбеддера: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "Не удалось определить размерность вектора для модели '{{modelId}}' с провайдером '{{provider}}'. Убедитесь, что 'Размерность эмбеддинга' правильно установлена в настройках провайдера, совместимого с OpenAI.",
"vectorDimensionNotDetermined": "Не удалось определить размерность вектора для модели '{{modelId}}' с провайдером '{{provider}}'. Проверьте профили модели или конфигурацию.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Gömücü oluşturmak için Ollama yapılandırması eksik",
"openAiCompatibleConfigMissing": "Gömücü oluşturmak için OpenAI uyumlu yapılandırması eksik",
"geminiConfigMissing": "Gömücü oluşturmak için Gemini yapılandırması eksik",
"mistralConfigMissing": "Gömücü oluşturmak için Mistral yapılandırması eksik",
"invalidEmbedderType": "Geçersiz gömücü türü yapılandırıldı: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "'{{provider}}' sağlayıcısı ile '{{modelId}}' modeli için vektör boyutu belirlenemedi. OpenAI uyumlu sağlayıcı ayarlarında 'Gömme Boyutu'nun doğru ayarlandığından emin ol.",
"vectorDimensionNotDetermined": "'{{provider}}' sağlayıcısı ile '{{modelId}}' modeli için vektör boyutu belirlenemedi. Model profillerini veya yapılandırmayı kontrol et.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "Thiếu cấu hình Ollama để tạo embedder",
"openAiCompatibleConfigMissing": "Thiếu cấu hình tương thích OpenAI để tạo embedder",
"geminiConfigMissing": "Thiếu cấu hình Gemini để tạo embedder",
"mistralConfigMissing": "Thiếu cấu hình Mistral để tạo trình nhúng",
"invalidEmbedderType": "Loại embedder được cấu hình không hợp lệ: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "Không thể xác định kích thước vector cho mô hình '{{modelId}}' với nhà cung cấp '{{provider}}'. Hãy đảm bảo 'Kích thước Embedding' được cài đặt đúng trong cài đặt nhà cung cấp tương thích OpenAI.",
"vectorDimensionNotDetermined": "Không thể xác định kích thước vector cho mô hình '{{modelId}}' với nhà cung cấp '{{provider}}'. Kiểm tra hồ sơ mô hình hoặc cấu hình.",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "创建嵌入器缺少 Ollama 配置",
"openAiCompatibleConfigMissing": "创建嵌入器缺少 OpenAI 兼容配置",
"geminiConfigMissing": "创建嵌入器缺少 Gemini 配置",
"mistralConfigMissing": "创建嵌入器时缺少 Mistral 配置",
"invalidEmbedderType": "配置的嵌入器类型无效:{{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "无法确定提供商 '{{provider}}' 的模型 '{{modelId}}' 的向量维度。请确保在 OpenAI 兼容提供商设置中正确设置了「嵌入维度」。",
"vectorDimensionNotDetermined": "无法确定提供商 '{{provider}}' 的模型 '{{modelId}}' 的向量维度。请检查模型配置文件或配置。",

View file

@ -46,6 +46,7 @@
"ollamaConfigMissing": "建立嵌入器缺少 Ollama 設定",
"openAiCompatibleConfigMissing": "建立嵌入器缺少 OpenAI 相容設定",
"geminiConfigMissing": "建立嵌入器缺少 Gemini 設定",
"mistralConfigMissing": "建立嵌入器時缺少 Mistral 設定",
"invalidEmbedderType": "設定的嵌入器類型無效:{{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "無法確定提供商 '{{provider}}' 的模型 '{{modelId}}' 的向量維度。請確保在 OpenAI 相容提供商設定中正確設定了「嵌入維度」。",
"vectorDimensionNotDetermined": "無法確定提供商 '{{provider}}' 的模型 '{{modelId}}' 的向量維度。請檢查模型設定檔或設定。",

View file

@ -306,6 +306,197 @@ describe("truncateOutput", () => {
const expectedLines = ["line1", "", "[...10 lines omitted...]", "", "line12", "line13", "line14", "line15"]
expect(resultLines).toEqual(expectedLines)
})
describe("character limit functionality", () => {
it("returns original content when no character limit provided", () => {
const content = "a".repeat(1000)
expect(truncateOutput(content, undefined, undefined)).toBe(content)
})
it("returns original content when characters are under limit", () => {
const content = "a".repeat(100)
expect(truncateOutput(content, undefined, 200)).toBe(content)
})
it("truncates content by character limit with 20/80 split", () => {
// Create content with 1000 characters
const content = "a".repeat(1000)
// Set character limit to 100
const result = truncateOutput(content, undefined, 100)
// Should keep:
// - First 20 characters (20% of 100)
// - Last 80 characters (80% of 100)
// - Omission indicator in between
const expectedStart = "a".repeat(20)
const expectedEnd = "a".repeat(80)
const expected = expectedStart + "\n[...900 characters omitted...]\n" + expectedEnd
expect(result).toBe(expected)
})
it("prioritizes character limit over line limit", () => {
// Create content with few lines but many characters per line
const longLine = "a".repeat(500)
const content = `${longLine}\n${longLine}\n${longLine}`
// Set both limits - character limit should take precedence
const result = truncateOutput(content, 10, 100)
// Should truncate by character limit, not line limit
const expectedStart = "a".repeat(20)
const expectedEnd = "a".repeat(80)
// Total content: 1502 chars, limit: 100, so 1402 chars omitted
const expected = expectedStart + "\n[...1402 characters omitted...]\n" + expectedEnd
expect(result).toBe(expected)
})
it("falls back to line limit when character limit is satisfied", () => {
// Create content with many short lines
const lines = Array.from({ length: 25 }, (_, i) => `line${i + 1}`)
const content = lines.join("\n")
// Character limit is high enough, so line limit should apply
const result = truncateOutput(content, 10, 10000)
// Should truncate by line limit
const expectedLines = [
"line1",
"line2",
"",
"[...15 lines omitted...]",
"",
"line18",
"line19",
"line20",
"line21",
"line22",
"line23",
"line24",
"line25",
]
expect(result).toBe(expectedLines.join("\n"))
})
it("handles edge case where character limit equals content length", () => {
const content = "exactly100chars".repeat(6) + "1234" // exactly 100 chars
const result = truncateOutput(content, undefined, 100)
expect(result).toBe(content)
})
it("handles very small character limits", () => {
const content = "a".repeat(1000)
const result = truncateOutput(content, undefined, 10)
// 20% of 10 = 2, 80% of 10 = 8
const expected = "aa\n[...990 characters omitted...]\n" + "a".repeat(8)
expect(result).toBe(expected)
})
it("handles character limit with mixed content", () => {
const content = "Hello world! This is a test with mixed content including numbers 123 and symbols @#$%"
const result = truncateOutput(content, undefined, 50)
// 20% of 50 = 10, 80% of 50 = 40
const expectedStart = content.slice(0, 10) // "Hello worl"
const expectedEnd = content.slice(-40) // last 40 chars
const omittedChars = content.length - 50
const expected = expectedStart + `\n[...${omittedChars} characters omitted...]\n` + expectedEnd
expect(result).toBe(expected)
})
describe("edge cases with very small character limits", () => {
it("handles character limit of 1", () => {
const content = "abcdefghijklmnopqrstuvwxyz"
const result = truncateOutput(content, undefined, 1)
// 20% of 1 = 0.2 (floor = 0), so beforeLimit = 0
// afterLimit = 1 - 0 = 1
// Should keep 0 chars from start and 1 char from end
const expected = "\n[...25 characters omitted...]\nz"
expect(result).toBe(expected)
})
it("handles character limit of 2", () => {
const content = "abcdefghijklmnopqrstuvwxyz"
const result = truncateOutput(content, undefined, 2)
// 20% of 2 = 0.4 (floor = 0), so beforeLimit = 0
// afterLimit = 2 - 0 = 2
// Should keep 0 chars from start and 2 chars from end
const expected = "\n[...24 characters omitted...]\nyz"
expect(result).toBe(expected)
})
it("handles character limit of 5", () => {
const content = "abcdefghijklmnopqrstuvwxyz"
const result = truncateOutput(content, undefined, 5)
// 20% of 5 = 1, so beforeLimit = 1
// afterLimit = 5 - 1 = 4
// Should keep 1 char from start and 4 chars from end
const expected = "a\n[...21 characters omitted...]\nwxyz"
expect(result).toBe(expected)
})
it("handles character limit with multi-byte characters", () => {
const content = "🚀🎉🔥💻🌟🎨🎯🎪🎭🎬" // 10 emojis, each is multi-byte
const result = truncateOutput(content, undefined, 10)
// Character limit works on string length, not byte count
// 20% of 10 = 2, 80% of 10 = 8
// Note: In JavaScript, each emoji is actually 2 characters (surrogate pair)
// So the content is actually 20 characters long, not 10
const expected = "🚀\n[...10 characters omitted...]\n🎯🎪🎭🎬"
expect(result).toBe(expected)
})
it("handles character limit with newlines in content", () => {
const content = "line1\nline2\nline3\nline4\nline5"
const result = truncateOutput(content, undefined, 15)
// Total length is 29 chars (including newlines)
// 20% of 15 = 3, 80% of 15 = 12
// The slice will take first 3 chars: "lin"
// And last 12 chars: "e4\nline5" (counting backwards)
const expected = "lin\n[...14 characters omitted...]\n\nline4\nline5"
expect(result).toBe(expected)
})
it("handles character limit exactly matching content with omission message", () => {
// Edge case: when the omission message would make output longer than original
const content = "short"
const result = truncateOutput(content, undefined, 10)
// Content is 5 chars, limit is 10, so no truncation needed
expect(result).toBe(content)
})
it("handles character limit smaller than omission message", () => {
const content = "a".repeat(100)
const result = truncateOutput(content, undefined, 3)
// 20% of 3 = 0.6 (floor = 0), so beforeLimit = 0
// afterLimit = 3 - 0 = 3
const expected = "\n[...97 characters omitted...]\naaa"
expect(result).toBe(expected)
})
it("prioritizes character limit even with very high line limit", () => {
const content = "a".repeat(1000)
const result = truncateOutput(content, 999999, 50)
// Character limit should still apply despite high line limit
const expectedStart = "a".repeat(10) // 20% of 50
const expectedEnd = "a".repeat(40) // 80% of 50
const expected = expectedStart + "\n[...950 characters omitted...]\n" + expectedEnd
expect(result).toBe(expected)
})
})
})
})
describe("applyRunLengthEncoding", () => {

View file

@ -135,17 +135,58 @@ export function stripLineNumbers(content: string, aggressive: boolean = false):
* When truncation is needed, it keeps 20% of the lines from the start and 80% from the end,
* with a clear indicator of how many lines were omitted in between.
*
* IMPORTANT: Character limit takes precedence over line limit. This is because:
* 1. Character limit provides a hard cap on memory usage and context window consumption
* 2. A single line with millions of characters could bypass line limits and cause issues
* 3. Character limit ensures consistent behavior regardless of line structure
*
* When both limits are specified:
* - If content exceeds character limit, character-based truncation is applied (regardless of line count)
* - If content is within character limit but exceeds line limit, line-based truncation is applied
* - This prevents edge cases where extremely long lines could consume excessive resources
*
* @param content The multi-line string to truncate
* @param lineLimit Optional maximum number of lines to keep. If not provided or 0, returns the original content
* @returns The truncated string with an indicator of omitted lines, or the original content if no truncation needed
* @param lineLimit Optional maximum number of lines to keep. If not provided or 0, no line limit is applied
* @param characterLimit Optional maximum number of characters to keep. If not provided or 0, no character limit is applied
* @returns The truncated string with an indicator of omitted content, or the original content if no truncation needed
*
* @example
* // With 10 line limit on 25 lines of content:
* // - Keeps first 2 lines (20% of 10)
* // - Keeps last 8 lines (80% of 10)
* // - Adds "[...15 lines omitted...]" in between
*
* @example
* // With character limit on long single line:
* // - Keeps first 20% of characters
* // - Keeps last 80% of characters
* // - Adds "[...X characters omitted...]" in between
*
* @example
* // Character limit takes precedence:
* // content = "A".repeat(50000) + "\n" + "B".repeat(50000) // 2 lines, 100,002 chars
* // truncateOutput(content, 10, 40000) // Uses character limit, not line limit
* // Result: First ~8000 chars + "[...60002 characters omitted...]" + Last ~32000 chars
*/
export function truncateOutput(content: string, lineLimit?: number): string {
export function truncateOutput(content: string, lineLimit?: number, characterLimit?: number): string {
// If no limits are specified, return original content
if (!lineLimit && !characterLimit) {
return content
}
// Character limit takes priority over line limit
if (characterLimit && content.length > characterLimit) {
const beforeLimit = Math.floor(characterLimit * 0.2) // 20% of characters before
const afterLimit = characterLimit - beforeLimit // remaining 80% after
const startSection = content.slice(0, beforeLimit)
const endSection = content.slice(-afterLimit)
const omittedChars = content.length - characterLimit
return startSection + `\n[...${omittedChars} characters omitted...]\n` + endSection
}
// If character limit is not exceeded or not specified, check line limit
if (!lineLimit) {
return content
}

View file

@ -1,4 +1,5 @@
import { truncateOutput, applyRunLengthEncoding, processBackspaces, processCarriageReturns } from "../misc/extract-text"
import { DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types"
import type {
RooTerminalProvider,
@ -262,11 +263,13 @@ export abstract class BaseTerminal implements RooTerminal {
}
/**
* Compresses terminal output by applying run-length encoding and truncating to line limit
* Compresses terminal output by applying run-length encoding and truncating to line and character limits
* @param input The terminal output to compress
* @param lineLimit Maximum number of lines to keep
* @param characterLimit Optional maximum number of characters to keep (defaults to DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT)
* @returns The compressed terminal output
*/
public static compressTerminalOutput(input: string, lineLimit: number): string {
public static compressTerminalOutput(input: string, lineLimit: number, characterLimit?: number): string {
let processedInput = input
if (BaseTerminal.compressProgressBar) {
@ -274,7 +277,10 @@ export abstract class BaseTerminal implements RooTerminal {
processedInput = processBackspaces(processedInput)
}
return truncateOutput(applyRunLengthEncoding(processedInput), lineLimit)
// Default character limit to prevent context window explosion
const effectiveCharLimit = characterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT
return truncateOutput(applyRunLengthEncoding(processedInput), lineLimit, effectiveCharLimit)
}
/**

View file

@ -3,7 +3,7 @@
"displayName": "%extension.displayName%",
"description": "%extension.description%",
"publisher": "RooVeterinaryInc",
"version": "3.23.14",
"version": "3.23.16",
"icon": "assets/icons/icon.png",
"galleryBanner": {
"color": "#617A91",

View file

@ -18,6 +18,7 @@ export class CodeIndexConfigManager {
private ollamaOptions?: ApiHandlerOptions
private openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
private geminiOptions?: { apiKey: string }
private mistralOptions?: { apiKey: string }
private qdrantUrl?: string = "http://localhost:6333"
private qdrantApiKey?: string
private searchMinScore?: number
@ -67,6 +68,7 @@ export class CodeIndexConfigManager {
const openAiCompatibleBaseUrl = codebaseIndexConfig.codebaseIndexOpenAiCompatibleBaseUrl ?? ""
const openAiCompatibleApiKey = this.contextProxy?.getSecret("codebaseIndexOpenAiCompatibleApiKey") ?? ""
const geminiApiKey = this.contextProxy?.getSecret("codebaseIndexGeminiApiKey") ?? ""
const mistralApiKey = this.contextProxy?.getSecret("codebaseIndexMistralApiKey") ?? ""
// Update instance variables with configuration
this.codebaseIndexEnabled = codebaseIndexEnabled ?? true
@ -100,6 +102,8 @@ export class CodeIndexConfigManager {
this.embedderProvider = "openai-compatible"
} else if (codebaseIndexEmbedderProvider === "gemini") {
this.embedderProvider = "gemini"
} else if (codebaseIndexEmbedderProvider === "mistral") {
this.embedderProvider = "mistral"
} else {
this.embedderProvider = "openai"
}
@ -119,6 +123,7 @@ export class CodeIndexConfigManager {
: undefined
this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey } : undefined
this.mistralOptions = mistralApiKey ? { apiKey: mistralApiKey } : undefined
}
/**
@ -135,6 +140,7 @@ export class CodeIndexConfigManager {
ollamaOptions?: ApiHandlerOptions
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
geminiOptions?: { apiKey: string }
mistralOptions?: { apiKey: string }
qdrantUrl?: string
qdrantApiKey?: string
searchMinScore?: number
@ -153,6 +159,7 @@ export class CodeIndexConfigManager {
openAiCompatibleBaseUrl: this.openAiCompatibleOptions?.baseUrl ?? "",
openAiCompatibleApiKey: this.openAiCompatibleOptions?.apiKey ?? "",
geminiApiKey: this.geminiOptions?.apiKey ?? "",
mistralApiKey: this.mistralOptions?.apiKey ?? "",
qdrantUrl: this.qdrantUrl ?? "",
qdrantApiKey: this.qdrantApiKey ?? "",
}
@ -176,6 +183,7 @@ export class CodeIndexConfigManager {
ollamaOptions: this.ollamaOptions,
openAiCompatibleOptions: this.openAiCompatibleOptions,
geminiOptions: this.geminiOptions,
mistralOptions: this.mistralOptions,
qdrantUrl: this.qdrantUrl,
qdrantApiKey: this.qdrantApiKey,
searchMinScore: this.currentSearchMinScore,
@ -208,6 +216,11 @@ export class CodeIndexConfigManager {
const qdrantUrl = this.qdrantUrl
const isConfigured = !!(apiKey && qdrantUrl)
return isConfigured
} else if (this.embedderProvider === "mistral") {
const apiKey = this.mistralOptions?.apiKey
const qdrantUrl = this.qdrantUrl
const isConfigured = !!(apiKey && qdrantUrl)
return isConfigured
}
return false // Should not happen if embedderProvider is always set correctly
}
@ -241,6 +254,7 @@ export class CodeIndexConfigManager {
const prevOpenAiCompatibleApiKey = prev?.openAiCompatibleApiKey ?? ""
const prevModelDimension = prev?.modelDimension
const prevGeminiApiKey = prev?.geminiApiKey ?? ""
const prevMistralApiKey = prev?.mistralApiKey ?? ""
const prevQdrantUrl = prev?.qdrantUrl ?? ""
const prevQdrantApiKey = prev?.qdrantApiKey ?? ""
@ -277,6 +291,7 @@ export class CodeIndexConfigManager {
const currentOpenAiCompatibleApiKey = this.openAiCompatibleOptions?.apiKey ?? ""
const currentModelDimension = this.modelDimension
const currentGeminiApiKey = this.geminiOptions?.apiKey ?? ""
const currentMistralApiKey = this.mistralOptions?.apiKey ?? ""
const currentQdrantUrl = this.qdrantUrl ?? ""
const currentQdrantApiKey = this.qdrantApiKey ?? ""
@ -295,6 +310,14 @@ export class CodeIndexConfigManager {
return true
}
if (prevGeminiApiKey !== currentGeminiApiKey) {
return true
}
if (prevMistralApiKey !== currentMistralApiKey) {
return true
}
// Check for model dimension changes (generic for all providers)
if (prevModelDimension !== currentModelDimension) {
return true
@ -351,6 +374,7 @@ export class CodeIndexConfigManager {
ollamaOptions: this.ollamaOptions,
openAiCompatibleOptions: this.openAiCompatibleOptions,
geminiOptions: this.geminiOptions,
mistralOptions: this.mistralOptions,
qdrantUrl: this.qdrantUrl,
qdrantApiKey: this.qdrantApiKey,
searchMinScore: this.currentSearchMinScore,

View file

@ -20,6 +20,7 @@ export const BATCH_SEGMENT_THRESHOLD = 60 // Number of code segments to batch fo
export const MAX_BATCH_RETRIES = 3
export const INITIAL_RETRY_DELAY_MS = 500
export const PARSING_CONCURRENCY = 10
export const MAX_PENDING_BATCHES = 20 // Maximum number of batches to accumulate before waiting
/**OpenAI Embedder */
export const MAX_BATCH_TOKENS = 100000

View file

@ -0,0 +1,193 @@
import { vitest, describe, it, expect, beforeEach } from "vitest"
import type { MockedClass } from "vitest"
import { MistralEmbedder } from "../mistral"
import { OpenAICompatibleEmbedder } from "../openai-compatible"
// Mock the OpenAICompatibleEmbedder
vitest.mock("../openai-compatible")
// Mock TelemetryService
vitest.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureEvent: vitest.fn(),
},
},
}))
const MockedOpenAICompatibleEmbedder = OpenAICompatibleEmbedder as MockedClass<typeof OpenAICompatibleEmbedder>
describe("MistralEmbedder", () => {
let embedder: MistralEmbedder
beforeEach(() => {
vitest.clearAllMocks()
})
describe("constructor", () => {
it("should create an instance with default model when no model specified", () => {
// Arrange
const apiKey = "test-mistral-api-key"
// Act
embedder = new MistralEmbedder(apiKey)
// Assert
expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith(
"https://api.mistral.ai/v1",
apiKey,
"codestral-embed-2505",
8191,
)
})
it("should create an instance with specified model", () => {
// Arrange
const apiKey = "test-mistral-api-key"
const modelId = "custom-embed-model"
// Act
embedder = new MistralEmbedder(apiKey, modelId)
// Assert
expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith(
"https://api.mistral.ai/v1",
apiKey,
"custom-embed-model",
8191,
)
})
it("should throw error when API key is not provided", () => {
// Act & Assert
expect(() => new MistralEmbedder("")).toThrow("validation.apiKeyRequired")
expect(() => new MistralEmbedder(null as any)).toThrow("validation.apiKeyRequired")
expect(() => new MistralEmbedder(undefined as any)).toThrow("validation.apiKeyRequired")
})
})
describe("embedderInfo", () => {
it("should return correct embedder info", () => {
// Arrange
embedder = new MistralEmbedder("test-api-key")
// Act
const info = embedder.embedderInfo
// Assert
expect(info).toEqual({
name: "mistral",
})
})
describe("createEmbeddings", () => {
let mockCreateEmbeddings: any
beforeEach(() => {
mockCreateEmbeddings = vitest.fn()
MockedOpenAICompatibleEmbedder.prototype.createEmbeddings = mockCreateEmbeddings
})
it("should use instance model when no model parameter provided", async () => {
// Arrange
embedder = new MistralEmbedder("test-api-key")
const texts = ["test text 1", "test text 2"]
const mockResponse = {
embeddings: [
[0.1, 0.2],
[0.3, 0.4],
],
}
mockCreateEmbeddings.mockResolvedValue(mockResponse)
// Act
const result = await embedder.createEmbeddings(texts)
// Assert
expect(mockCreateEmbeddings).toHaveBeenCalledWith(texts, "codestral-embed-2505")
expect(result).toEqual(mockResponse)
})
it("should use provided model parameter when specified", async () => {
// Arrange
embedder = new MistralEmbedder("test-api-key", "custom-embed-model")
const texts = ["test text 1", "test text 2"]
const mockResponse = {
embeddings: [
[0.1, 0.2],
[0.3, 0.4],
],
}
mockCreateEmbeddings.mockResolvedValue(mockResponse)
// Act
const result = await embedder.createEmbeddings(texts, "codestral-embed-2505")
// Assert
expect(mockCreateEmbeddings).toHaveBeenCalledWith(texts, "codestral-embed-2505")
expect(result).toEqual(mockResponse)
})
it("should handle errors from OpenAICompatibleEmbedder", async () => {
// Arrange
embedder = new MistralEmbedder("test-api-key")
const texts = ["test text"]
const error = new Error("Embedding failed")
mockCreateEmbeddings.mockRejectedValue(error)
// Act & Assert
await expect(embedder.createEmbeddings(texts)).rejects.toThrow("Embedding failed")
})
})
})
describe("validateConfiguration", () => {
let mockValidateConfiguration: any
beforeEach(() => {
mockValidateConfiguration = vitest.fn()
MockedOpenAICompatibleEmbedder.prototype.validateConfiguration = mockValidateConfiguration
})
it("should delegate validation to OpenAICompatibleEmbedder", async () => {
// Arrange
embedder = new MistralEmbedder("test-api-key")
mockValidateConfiguration.mockResolvedValue({ valid: true })
// Act
const result = await embedder.validateConfiguration()
// Assert
expect(mockValidateConfiguration).toHaveBeenCalled()
expect(result).toEqual({ valid: true })
})
it("should pass through validation errors from OpenAICompatibleEmbedder", async () => {
// Arrange
embedder = new MistralEmbedder("test-api-key")
mockValidateConfiguration.mockResolvedValue({
valid: false,
error: "embeddings:validation.authenticationFailed",
})
// Act
const result = await embedder.validateConfiguration()
// Assert
expect(mockValidateConfiguration).toHaveBeenCalled()
expect(result).toEqual({
valid: false,
error: "embeddings:validation.authenticationFailed",
})
})
it("should handle validation exceptions", async () => {
// Arrange
embedder = new MistralEmbedder("test-api-key")
mockValidateConfiguration.mockRejectedValue(new Error("Validation failed"))
// Act & Assert
await expect(embedder.validateConfiguration()).rejects.toThrow("Validation failed")
})
})
})

View file

@ -0,0 +1,213 @@
import { describe, it, expect, vi, beforeEach, afterEach, MockedClass, MockedFunction } from "vitest"
import { OpenAI } from "openai"
import { OpenAICompatibleEmbedder } from "../openai-compatible"
// Mock the OpenAI SDK
vi.mock("openai")
// Mock TelemetryService
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureEvent: vi.fn(),
},
},
}))
// Mock i18n
vi.mock("../../../../i18n", () => ({
t: (key: string, params?: Record<string, any>) => {
const translations: Record<string, string> = {
"embeddings:rateLimitRetry": `Rate limit hit, retrying in ${params?.delayMs}ms (attempt ${params?.attempt}/${params?.maxRetries})`,
"embeddings:failedMaxAttempts": `Failed to create embeddings after ${params?.attempts} attempts`,
"embeddings:failedWithStatus": `Failed to create embeddings after ${params?.attempts} attempts: HTTP ${params?.statusCode} - ${params?.errorMessage}`,
"embeddings:failedWithError": `Failed to create embeddings after ${params?.attempts} attempts: ${params?.errorMessage}`,
}
return translations[key] || key
},
}))
const MockedOpenAI = OpenAI as MockedClass<typeof OpenAI>
describe("OpenAICompatibleEmbedder - Global Rate Limiting", () => {
let mockOpenAIInstance: any
let mockEmbeddingsCreate: MockedFunction<any>
const testBaseUrl = "https://api.openai.com/v1"
const testApiKey = "test-api-key"
const testModelId = "text-embedding-3-small"
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.spyOn(console, "warn").mockImplementation(() => {})
vi.spyOn(console, "error").mockImplementation(() => {})
// Setup mock OpenAI instance
mockEmbeddingsCreate = vi.fn()
mockOpenAIInstance = {
embeddings: {
create: mockEmbeddingsCreate,
},
}
MockedOpenAI.mockImplementation(() => mockOpenAIInstance)
// Reset global rate limit state
const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
;(embedder as any).constructor.globalRateLimitState = {
isRateLimited: false,
rateLimitResetTime: 0,
consecutiveRateLimitErrors: 0,
lastRateLimitError: 0,
mutex: (embedder as any).constructor.globalRateLimitState.mutex,
}
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it("should apply global rate limiting across multiple batch requests", async () => {
const embedder1 = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
const embedder2 = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
// First batch hits rate limit
const rateLimitError = new Error("Rate limit exceeded") as any
rateLimitError.status = 429
mockEmbeddingsCreate
.mockRejectedValueOnce(rateLimitError) // First attempt fails
.mockResolvedValue({
data: [{ embedding: "base64encodeddata" }],
usage: { prompt_tokens: 10, total_tokens: 15 },
})
// Start first batch request
const batch1Promise = embedder1.createEmbeddings(["test1"])
// Advance time slightly to let the first request fail and set global rate limit
await vi.advanceTimersByTimeAsync(100)
// Start second batch request while global rate limit is active
const batch2Promise = embedder2.createEmbeddings(["test2"])
// Check that global rate limit was set
const state = (embedder1 as any).constructor.globalRateLimitState
expect(state.isRateLimited).toBe(true)
expect(state.consecutiveRateLimitErrors).toBe(1)
// Advance time to complete rate limit delay (5 seconds base delay)
await vi.advanceTimersByTimeAsync(5000)
// Both requests should complete
const [result1, result2] = await Promise.all([batch1Promise, batch2Promise])
expect(result1.embeddings).toHaveLength(1)
expect(result2.embeddings).toHaveLength(1)
// The second embedder should have waited for the global rate limit
// No logging expected - we've removed it to prevent log flooding
})
it("should track consecutive rate limit errors", async () => {
const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
const state = (embedder as any).constructor.globalRateLimitState
const rateLimitError = new Error("Rate limit exceeded") as any
rateLimitError.status = 429
// Test that consecutive errors increment when they happen quickly
// Mock multiple rate limit errors in a single request
mockEmbeddingsCreate
.mockRejectedValueOnce(rateLimitError) // First attempt
.mockRejectedValueOnce(rateLimitError) // Retry 1
.mockResolvedValueOnce({
data: [{ embedding: "base64encodeddata" }],
usage: { prompt_tokens: 10, total_tokens: 15 },
})
const promise1 = embedder.createEmbeddings(["test1"])
// Wait for first attempt to fail
await vi.advanceTimersByTimeAsync(100)
expect(state.consecutiveRateLimitErrors).toBe(1)
// Wait for first retry (500ms) to also fail
await vi.advanceTimersByTimeAsync(500)
// The state should show 2 consecutive errors now
// Note: The count might be 1 if the global rate limit kicked in before the second attempt
expect(state.consecutiveRateLimitErrors).toBeGreaterThanOrEqual(1)
// Wait for the global rate limit and successful retry
await vi.advanceTimersByTimeAsync(20000)
await promise1
// Verify the delay increases with consecutive errors
// Make another request immediately that also hits rate limit
mockEmbeddingsCreate.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce({
data: [{ embedding: "base64encodeddata" }],
usage: { prompt_tokens: 10, total_tokens: 15 },
})
// Store the current consecutive count before the next request
const previousCount = state.consecutiveRateLimitErrors
const promise2 = embedder.createEmbeddings(["test2"])
await vi.advanceTimersByTimeAsync(100)
// Should have incremented from the previous count
expect(state.consecutiveRateLimitErrors).toBeGreaterThan(previousCount)
// Complete the second request
await vi.advanceTimersByTimeAsync(20000)
await promise2
})
it("should reset consecutive error count after time passes", async () => {
const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
const state = (embedder as any).constructor.globalRateLimitState
// Manually set state to simulate previous errors
state.consecutiveRateLimitErrors = 3
state.lastRateLimitError = Date.now() - 70000 // 70 seconds ago
const rateLimitError = new Error("Rate limit exceeded") as any
rateLimitError.status = 429
mockEmbeddingsCreate.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce({
data: [{ embedding: "base64encodeddata" }],
usage: { prompt_tokens: 10, total_tokens: 15 },
})
// Trigger the updateGlobalRateLimitState method
await (embedder as any).updateGlobalRateLimitState(rateLimitError)
// Should reset to 1 since more than 60 seconds passed
expect(state.consecutiveRateLimitErrors).toBe(1)
})
it("should not exceed maximum delay of 5 minutes", async () => {
const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
const state = (embedder as any).constructor.globalRateLimitState
// Set state to simulate many consecutive errors
state.consecutiveRateLimitErrors = 10 // This would normally result in a very long delay
const rateLimitError = new Error("Rate limit exceeded") as any
rateLimitError.status = 429
// Trigger the updateGlobalRateLimitState method
await (embedder as any).updateGlobalRateLimitState(rateLimitError)
// Calculate the expected delay
const now = Date.now()
const delay = state.rateLimitResetTime - now
// Should be capped at 5 minutes (300000ms)
expect(delay).toBeLessThanOrEqual(300000)
expect(delay).toBeGreaterThan(0)
})
})

View file

@ -60,6 +60,16 @@ describe("OpenAICompatibleEmbedder", () => {
}
MockedOpenAI.mockImplementation(() => mockOpenAIInstance)
// Reset global rate limit state to prevent interference between tests
const tempEmbedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
;(tempEmbedder as any).constructor.globalRateLimitState = {
isRateLimited: false,
rateLimitResetTime: 0,
consecutiveRateLimitErrors: 0,
lastRateLimitError: 0,
mutex: (tempEmbedder as any).constructor.globalRateLimitState.mutex,
}
})
afterEach(() => {
@ -385,9 +395,17 @@ describe("OpenAICompatibleEmbedder", () => {
const resultPromise = embedder.createEmbeddings(testTexts)
// Fast-forward through the delays
await vitest.advanceTimersByTimeAsync(INITIAL_RETRY_DELAY_MS) // First retry delay
await vitest.advanceTimersByTimeAsync(INITIAL_RETRY_DELAY_MS * 2) // Second retry delay
// First attempt fails immediately, triggering global rate limit (5s)
await vitest.advanceTimersByTimeAsync(100)
// Wait for global rate limit delay
await vitest.advanceTimersByTimeAsync(5000)
// Second attempt also fails, increasing delay
await vitest.advanceTimersByTimeAsync(100)
// Wait for increased global rate limit delay (10s)
await vitest.advanceTimersByTimeAsync(10000)
const result = await resultPromise
@ -445,7 +463,7 @@ describe("OpenAICompatibleEmbedder", () => {
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("OpenAI Compatible embedder error"),
expect.any(Error),
apiError,
)
})
@ -461,7 +479,7 @@ describe("OpenAICompatibleEmbedder", () => {
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("OpenAI Compatible embedder error"),
batchError,
expect.any(Error),
)
})
@ -791,10 +809,23 @@ describe("OpenAICompatibleEmbedder", () => {
)
const resultPromise = embedder.createEmbeddings(["test"])
await vitest.advanceTimersByTimeAsync(INITIAL_RETRY_DELAY_MS * 3)
// First attempt fails, triggering global rate limit
await vitest.advanceTimersByTimeAsync(100)
// Wait for global rate limit (5s)
await vitest.advanceTimersByTimeAsync(5000)
// Second attempt also fails
await vitest.advanceTimersByTimeAsync(100)
// Wait for increased global rate limit (10s)
await vitest.advanceTimersByTimeAsync(10000)
const result = await resultPromise
expect(global.fetch).toHaveBeenCalledTimes(3)
// Check that rate limit warnings were logged
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Rate limit hit"))
expectEmbeddingValues(result.embeddings[0], [0.1, 0.2, 0.3])
vitest.useRealTimers()

View file

@ -0,0 +1,91 @@
import { OpenAICompatibleEmbedder } from "./openai-compatible"
import { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder"
import { MAX_ITEM_TOKENS } from "../constants"
import { t } from "../../../i18n"
import { TelemetryEventName } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
/**
* Mistral embedder implementation that wraps the OpenAI Compatible embedder
* with configuration for Mistral's embedding API.
*
* Supported models:
* - codestral-embed-2505 (dimension: 1536)
*/
export class MistralEmbedder implements IEmbedder {
private readonly openAICompatibleEmbedder: OpenAICompatibleEmbedder
private static readonly MISTRAL_BASE_URL = "https://api.mistral.ai/v1"
private static readonly DEFAULT_MODEL = "codestral-embed-2505"
private readonly modelId: string
/**
* Creates a new Mistral embedder
* @param apiKey The Mistral API key for authentication
* @param modelId The model ID to use (defaults to codestral-embed-2505)
*/
constructor(apiKey: string, modelId?: string) {
if (!apiKey) {
throw new Error(t("embeddings:validation.apiKeyRequired"))
}
// Use provided model or default
this.modelId = modelId || MistralEmbedder.DEFAULT_MODEL
// Create an OpenAI Compatible embedder with Mistral's configuration
this.openAICompatibleEmbedder = new OpenAICompatibleEmbedder(
MistralEmbedder.MISTRAL_BASE_URL,
apiKey,
this.modelId,
MAX_ITEM_TOKENS, // This is the max token limit (8191), not the embedding dimension
)
}
/**
* Creates embeddings for the given texts using Mistral's embedding API
* @param texts Array of text strings to embed
* @param model Optional model identifier (uses constructor model if not provided)
* @returns Promise resolving to embedding response
*/
async createEmbeddings(texts: string[], model?: string): Promise<EmbeddingResponse> {
try {
// Use the provided model or fall back to the instance's model
const modelToUse = model || this.modelId
return await this.openAICompatibleEmbedder.createEmbeddings(texts, modelToUse)
} catch (error) {
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
location: "MistralEmbedder:createEmbeddings",
})
throw error
}
}
/**
* Validates the Mistral embedder configuration by delegating to the underlying OpenAI-compatible embedder
* @returns Promise resolving to validation result with success status and optional error message
*/
async validateConfiguration(): Promise<{ valid: boolean; error?: string }> {
try {
// Delegate validation to the OpenAI-compatible embedder
// The error messages will be specific to Mistral since we're using Mistral's base URL
return await this.openAICompatibleEmbedder.validateConfiguration()
} catch (error) {
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
location: "MistralEmbedder:validateConfiguration",
})
throw error
}
}
/**
* Returns information about this embedder
*/
get embedderInfo(): EmbedderInfo {
return {
name: "mistral",
}
}
}

View file

@ -11,6 +11,7 @@ import { t } from "../../../i18n"
import { withValidationErrorHandling, HttpError, formatEmbeddingError } from "../shared/validation-helpers"
import { TelemetryEventName } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { Mutex } from "async-mutex"
interface EmbeddingItem {
embedding: string | number[]
@ -38,6 +39,16 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
private readonly isFullUrl: boolean
private readonly maxItemTokens: number
// Global rate limiting state shared across all instances
private static globalRateLimitState = {
isRateLimited: false,
rateLimitResetTime: 0,
consecutiveRateLimitErrors: 0,
lastRateLimitError: 0,
// Mutex to ensure thread-safe access to rate limit state
mutex: new Mutex(),
}
/**
* Creates a new OpenAI Compatible embedder
* @param baseUrl The base URL for the OpenAI-compatible API endpoint
@ -239,6 +250,9 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
const isFullUrl = this.isFullUrl
for (let attempts = 0; attempts < MAX_RETRIES; attempts++) {
// Check global rate limit before attempting request
await this.waitForGlobalRateLimit()
try {
let response: OpenAIEmbeddingResponse
@ -298,17 +312,26 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
// Check if it's a rate limit error
const httpError = error as HttpError
if (httpError?.status === 429 && hasMoreAttempts) {
const delayMs = INITIAL_DELAY_MS * Math.pow(2, attempts)
console.warn(
t("embeddings:rateLimitRetry", {
delayMs,
attempt: attempts + 1,
maxRetries: MAX_RETRIES,
}),
)
await new Promise((resolve) => setTimeout(resolve, delayMs))
continue
if (httpError?.status === 429) {
// Update global rate limit state
await this.updateGlobalRateLimitState(httpError)
if (hasMoreAttempts) {
// Calculate delay based on global rate limit state
const baseDelay = INITIAL_DELAY_MS * Math.pow(2, attempts)
const globalDelay = await this.getGlobalRateLimitDelay()
const delayMs = Math.max(baseDelay, globalDelay)
console.warn(
t("embeddings:rateLimitRetry", {
delayMs,
attempt: attempts + 1,
maxRetries: MAX_RETRIES,
}),
)
await new Promise((resolve) => setTimeout(resolve, delayMs))
continue
}
}
// Log the error for debugging
@ -376,4 +399,87 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
name: "openai-compatible",
}
}
/**
* Waits if there's an active global rate limit
*/
private async waitForGlobalRateLimit(): Promise<void> {
const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire()
try {
const state = OpenAICompatibleEmbedder.globalRateLimitState
if (state.isRateLimited && state.rateLimitResetTime > Date.now()) {
const waitTime = state.rateLimitResetTime - Date.now()
// Silent wait - no logging to prevent flooding
release() // Release mutex before waiting
await new Promise((resolve) => setTimeout(resolve, waitTime))
return
}
// Reset rate limit if time has passed
if (state.isRateLimited && state.rateLimitResetTime <= Date.now()) {
state.isRateLimited = false
state.consecutiveRateLimitErrors = 0
}
} finally {
// Only release if we haven't already
try {
release()
} catch {
// Already released
}
}
}
/**
* Updates global rate limit state when a 429 error occurs
*/
private async updateGlobalRateLimitState(error: HttpError): Promise<void> {
const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire()
try {
const state = OpenAICompatibleEmbedder.globalRateLimitState
const now = Date.now()
// Increment consecutive rate limit errors
if (now - state.lastRateLimitError < 60000) {
// Within 1 minute
state.consecutiveRateLimitErrors++
} else {
state.consecutiveRateLimitErrors = 1
}
state.lastRateLimitError = now
// Calculate exponential backoff based on consecutive errors
const baseDelay = 5000 // 5 seconds base
const maxDelay = 300000 // 5 minutes max
const exponentialDelay = Math.min(baseDelay * Math.pow(2, state.consecutiveRateLimitErrors - 1), maxDelay)
// Set global rate limit
state.isRateLimited = true
state.rateLimitResetTime = now + exponentialDelay
// Silent rate limit activation - no logging to prevent flooding
} finally {
release()
}
}
/**
* Gets the current global rate limit delay
*/
private async getGlobalRateLimitDelay(): Promise<number> {
const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire()
try {
const state = OpenAICompatibleEmbedder.globalRateLimitState
if (state.isRateLimited && state.rateLimitResetTime > Date.now()) {
return state.rateLimitResetTime - Date.now()
}
return 0
} finally {
release()
}
}
}

View file

@ -13,6 +13,7 @@ export interface CodeIndexConfig {
ollamaOptions?: ApiHandlerOptions
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
geminiOptions?: { apiKey: string }
mistralOptions?: { apiKey: string }
qdrantUrl?: string
qdrantApiKey?: string
searchMinScore?: number
@ -33,6 +34,7 @@ export type PreviousConfigSnapshot = {
openAiCompatibleBaseUrl?: string
openAiCompatibleApiKey?: string
geminiApiKey?: string
mistralApiKey?: string
qdrantUrl?: string
qdrantApiKey?: string
}

View file

@ -28,7 +28,7 @@ export interface EmbeddingResponse {
}
}
export type AvailableEmbedders = "openai" | "ollama" | "openai-compatible" | "gemini"
export type AvailableEmbedders = "openai" | "ollama" | "openai-compatible" | "gemini" | "mistral"
export interface EmbedderInfo {
name: AvailableEmbedders

View file

@ -70,7 +70,7 @@ export interface ICodeIndexManager {
}
export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error"
export type EmbedderProvider = "openai" | "ollama" | "openai-compatible" | "gemini"
export type EmbedderProvider = "openai" | "ollama" | "openai-compatible" | "gemini" | "mistral"
export interface IndexProgressUpdate {
systemStatus: IndexingState

View file

@ -23,6 +23,7 @@ import {
INITIAL_RETRY_DELAY_MS,
PARSING_CONCURRENCY,
BATCH_PROCESSING_CONCURRENCY,
MAX_PENDING_BATCHES,
} from "../constants"
import { isPathInIgnoredDirectory } from "../../glob/ignore-utils"
import { TelemetryService } from "@roo-code/telemetry"
@ -98,6 +99,7 @@ export class DirectoryScanner implements IDirectoryScanner {
let currentBatchTexts: string[] = []
let currentBatchFileInfos: { filePath: string; fileHash: string; isNew: boolean }[] = []
const activeBatchPromises = new Set<Promise<void>>()
let pendingBatchCount = 0
// Initialize block counter
let totalBlockCount = 0
@ -152,6 +154,12 @@ export class DirectoryScanner implements IDirectoryScanner {
// Check if batch threshold is met
if (currentBatchBlocks.length >= BATCH_SEGMENT_THRESHOLD) {
// Wait if we've reached the maximum pending batches
while (pendingBatchCount >= MAX_PENDING_BATCHES) {
// Wait for at least one batch to complete
await Promise.race(activeBatchPromises)
}
// Copy current batch data and clear accumulators
const batchBlocks = [...currentBatchBlocks]
const batchTexts = [...currentBatchTexts]
@ -160,6 +168,9 @@ export class DirectoryScanner implements IDirectoryScanner {
currentBatchTexts = []
currentBatchFileInfos = []
// Increment pending batch count
pendingBatchCount++
// Queue batch processing
const batchPromise = batchLimiter(() =>
this.processBatch(
@ -176,6 +187,7 @@ export class DirectoryScanner implements IDirectoryScanner {
// Clean up completed promises to prevent memory accumulation
batchPromise.finally(() => {
activeBatchPromises.delete(batchPromise)
pendingBatchCount--
})
}
} finally {
@ -238,6 +250,9 @@ export class DirectoryScanner implements IDirectoryScanner {
currentBatchTexts = []
currentBatchFileInfos = []
// Increment pending batch count for final batch
pendingBatchCount++
// Queue final batch processing
const batchPromise = batchLimiter(() =>
this.processBatch(batchBlocks, batchTexts, batchFileInfos, scanWorkspace, onError, onBlocksIndexed),
@ -247,6 +262,7 @@ export class DirectoryScanner implements IDirectoryScanner {
// Clean up completed promises to prevent memory accumulation
batchPromise.finally(() => {
activeBatchPromises.delete(batchPromise)
pendingBatchCount--
})
} finally {
release()

View file

@ -3,6 +3,7 @@ import { OpenAiEmbedder } from "./embedders/openai"
import { CodeIndexOllamaEmbedder } from "./embedders/ollama"
import { OpenAICompatibleEmbedder } from "./embedders/openai-compatible"
import { GeminiEmbedder } from "./embedders/gemini"
import { MistralEmbedder } from "./embedders/mistral"
import { EmbedderProvider, getDefaultModelId, getModelDimension } from "../../shared/embeddingModels"
import { QdrantVectorStore } from "./vector-store/qdrant-client"
import { codeParser, DirectoryScanner, FileWatcher } from "./processors"
@ -64,6 +65,11 @@ export class CodeIndexServiceFactory {
throw new Error(t("embeddings:serviceFactory.geminiConfigMissing"))
}
return new GeminiEmbedder(config.geminiOptions.apiKey, config.modelId)
} else if (provider === "mistral") {
if (!config.mistralOptions?.apiKey) {
throw new Error(t("embeddings:serviceFactory.mistralConfigMissing"))
}
return new MistralEmbedder(config.mistralOptions.apiKey, config.modelId)
}
throw new Error(

View file

@ -93,7 +93,6 @@ describe("McpHub", () => {
// Mock console.error to suppress error messages during tests
console.error = vi.fn()
const mockUri: Uri = {
scheme: "file",
authority: "",

View file

@ -206,6 +206,7 @@ export type ExtensionState = Pick<
// | "maxReadFileLine" // Optional in GlobalSettings, required here.
| "maxConcurrentFileReads" // Optional in GlobalSettings, required here.
| "terminalOutputLineLimit"
| "terminalOutputCharacterLimit"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"
| "terminalCommandDelay"

View file

@ -116,6 +116,7 @@ export interface WebviewMessage {
| "submitEditedMessage"
| "editMessageConfirm"
| "terminalOutputLineLimit"
| "terminalOutputCharacterLimit"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"
| "terminalCommandDelay"
@ -245,7 +246,7 @@ export interface WebviewMessage {
// Global state settings
codebaseIndexEnabled: boolean
codebaseIndexQdrantUrl: string
codebaseIndexEmbedderProvider: "openai" | "ollama" | "openai-compatible" | "gemini"
codebaseIndexEmbedderProvider: "openai" | "ollama" | "openai-compatible" | "gemini" | "mistral"
codebaseIndexEmbedderBaseUrl?: string
codebaseIndexEmbedderModelId: string
codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers
@ -258,6 +259,7 @@ export interface WebviewMessage {
codeIndexQdrantApiKey?: string
codebaseIndexOpenAiCompatibleApiKey?: string
codebaseIndexGeminiApiKey?: string
codebaseIndexMistralApiKey?: string
}
}

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