diff --git a/.gitattributes b/.gitattributes index 02ddd6b634..284eab4f98 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index aad45c9bc6..2323f03354 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -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 ` + - Run tests with: `npx vitest run ` - 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: diff --git a/.roomodes b/.roomodes index 15d3673a09..d98a0fda7d 100644 --- a/.roomodes +++ b/.roomodes @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 91091805e8..7eca706181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/web-evals/scripts/check-services.sh b/apps/web-evals/scripts/check-services.sh index fd1e74997c..104a472208 100755 --- a/apps/web-evals/scripts/check-services.sh +++ b/apps/web-evals/scripts/check-services.sh @@ -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 diff --git a/apps/web-evals/src/actions/runs.ts b/apps/web-evals/src/actions/runs.ts index 90387d3257..be4664d4d3 100644 --- a/apps/web-evals/src/actions/runs.ts +++ b/apps/web-evals/src/actions/runs.ts @@ -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. }) diff --git a/apps/web-evals/src/app/runs/new/new-run.tsx b/apps/web-evals/src/app/runs/new/new-run.tsx index 444086bd59..f8633611b6 100644 --- a/apps/web-evals/src/app/runs/new/new-run.tsx +++ b/apps/web-evals/src/app/runs/new/new-run.tsx @@ -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() { )} /> + ( + + Timeout (Minutes) + +
+ field.onChange(value[0])} + /> +
{field.value}
+
+
+ +
+ )} + /> + data.suite === "full" || (data.exercises || []).length > 0, { diff --git a/apps/web-roo-code/src/components/chromes/footer.tsx b/apps/web-roo-code/src/components/chromes/footer.tsx index 57d4c8ae8b..4c2b036190 100644 --- a/apps/web-roo-code/src/components/chromes/footer.tsx +++ b/apps/web-roo-code/src/components/chromes/footer.tsx @@ -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() { LinkedIn + + + Bluesky + TikTok - - Bluesky + + YouTube diff --git a/apps/web-roo-code/src/lib/constants.ts b/apps/web-roo-code/src/lib/constants.ts index 7910d83f2b..9f769e2967 100644 --- a/apps/web-roo-code/src/lib/constants.ts +++ b/apps/web-roo-code/src/lib/constants.ts @@ -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", diff --git a/package.json b/package.json index 61f1f6cdaf..99becf0a0c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cloud/src/auth/WebAuthService.ts b/packages/cloud/src/auth/WebAuthService.ts index d14cbe67d8..82d3122426 100644 --- a/packages/cloud/src/auth/WebAuthService.ts +++ b/packages/cloud/src/auth/WebAuthService.ts @@ -494,7 +494,7 @@ export class WebAuthService extends EventEmitter 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}`) diff --git a/packages/evals/.env.development b/packages/evals/.env.development index 7970806bec..58f781d246 100644 --- a/packages/evals/.env.development +++ b/packages/evals/.env.development @@ -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 diff --git a/packages/evals/.env.test b/packages/evals/.env.test index 2ad09c3734..8ecf74f435 100644 --- a/packages/evals/.env.test +++ b/packages/evals/.env.test @@ -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 diff --git a/packages/evals/README.md b/packages/evals/README.md index 7ef5be139b..750454956f 100644 --- a/packages/evals/README.md +++ b/packages/evals/README.md @@ -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: diff --git a/packages/evals/package.json b/packages/evals/package.json index 3d1cfb3e92..83690a99c4 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -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:^", diff --git a/packages/evals/src/cli/redis.ts b/packages/evals/src/cli/redis.ts index 8f2c164e49..7e6fa77da5 100644 --- a/packages/evals/src/cli/redis.ts +++ b/packages/evals/src/cli/redis.ts @@ -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 }) => { diff --git a/packages/evals/src/cli/runTask.ts b/packages/evals/src/cli/runTask.ts index 507d614ea5..0683cd7238 100644 --- a/packages/evals/src/cli/runTask.ts +++ b/packages/evals/src/cli/runTask.ts @@ -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 diff --git a/packages/evals/src/db/migrations/0001_add_timeout_to_runs.sql b/packages/evals/src/db/migrations/0001_add_timeout_to_runs.sql new file mode 100644 index 0000000000..16d3cc1bdd --- /dev/null +++ b/packages/evals/src/db/migrations/0001_add_timeout_to_runs.sql @@ -0,0 +1 @@ +ALTER TABLE "runs" ADD COLUMN "timeout" integer DEFAULT 5 NOT NULL; \ No newline at end of file diff --git a/packages/evals/src/db/migrations/0001_lowly_captain_flint.sql b/packages/evals/src/db/migrations/0001_lowly_captain_flint.sql new file mode 100644 index 0000000000..16d3cc1bdd --- /dev/null +++ b/packages/evals/src/db/migrations/0001_lowly_captain_flint.sql @@ -0,0 +1 @@ +ALTER TABLE "runs" ADD COLUMN "timeout" integer DEFAULT 5 NOT NULL; \ No newline at end of file diff --git a/packages/evals/src/db/migrations/meta/0001_snapshot.json b/packages/evals/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000000..194fd6055c --- /dev/null +++ b/packages/evals/src/db/migrations/meta/0001_snapshot.json @@ -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": {} + } +} diff --git a/packages/evals/src/db/migrations/meta/_journal.json b/packages/evals/src/db/migrations/meta/_journal.json index b26aac5417..e20425b105 100644 --- a/packages/evals/src/db/migrations/meta/_journal.json +++ b/packages/evals/src/db/migrations/meta/_journal.json @@ -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 } ] } diff --git a/packages/evals/src/db/queries/__tests__/copyRun.spec.ts b/packages/evals/src/db/queries/__tests__/copyRun.spec.ts index c693e471db..079373d568 100644 --- a/packages/evals/src/db/queries/__tests__/copyRun.spec.ts +++ b/packages/evals/src/db/queries/__tests__/copyRun.spec.ts @@ -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 }) diff --git a/packages/evals/src/db/schema.ts b/packages/evals/src/db/schema.ts index 0338b812e2..73705ac054 100644 --- a/packages/evals/src/db/schema.ts +++ b/packages/evals/src/db/schema.ts @@ -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(), diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index 87c5bbcc1c..8277320289 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -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") }) }) diff --git a/packages/types/src/codebase-index.ts b/packages/types/src/codebase-index.ts index 0ad19d8676..89d5b168d7 100644 --- a/packages/types/src/codebase-index.ts +++ b/packages/types/src/codebase-index.ts @@ -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 @@ -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 diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index bddb94cc9e..30521f2c68 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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 @@ -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, diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index be74ae6bb4..884337767f 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -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" } diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 267401bd91..e4e506b8a7 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -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" diff --git a/packages/types/src/providers/moonshot.ts b/packages/types/src/providers/moonshot.ts new file mode 100644 index 0000000000..d7e542077d --- /dev/null +++ b/packages/types/src/providers/moonshot.ts @@ -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 + +export const MOONSHOT_DEFAULT_TEMPERATURE = 0.6 diff --git a/src/api/index.ts b/src/api/index.ts index 960209f714..4598a711b2 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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) } } diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts new file mode 100644 index 0000000000..586dd5598e --- /dev/null +++ b/src/api/providers/__tests__/moonshot.spec.ts @@ -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() + }) + }) +}) diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 93df5c58ff..89d4c203ad 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -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" diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts new file mode 100644 index 0000000000..f04b369d1c --- /dev/null +++ b/src/api/providers/moonshot.ts @@ -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, + } + } +} diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index 3da22d1be8..3e1ccb5571 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -558,48 +558,54 @@ export class CustomModesManager { */ public async checkRulesDirectoryHasContent(slug: string): Promise { 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 { + // 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 diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 7823a3040a..350c8136f2 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -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. */ diff --git a/src/core/config/__tests__/CustomModesManager.spec.ts b/src/core/config/__tests__/CustomModesManager.spec.ts index c325a27f75..682696fd03 100644 --- a/src/core/config/__tests__/CustomModesManager.spec.ts +++ b/src/core/config/__tests__/CustomModesManager.spec.ts @@ -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") + }) }) }) diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 6d24c63101..e52c1974b6 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -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")) diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 6f0c9fe2bf..b83b37c75b 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -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}`) } } diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap index e80857d354..273e43d20b 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap @@ -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. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap index 8563bef9cc..273e43d20b 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap @@ -269,55 +269,6 @@ Examples: true -## 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: - -server name here -tool name here - -{ - "param1": "value1", - "param2": "value2" -} - - - -Example: Requesting to use an MCP tool - - -weather-server -get_forecast - -{ - "city": "San Francisco", - "days": 5 -} - - - -## 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: - -server name here -resource URI here - - -Example: Requesting to access an MCP resource - - -weather-server -weather://san-francisco/current - - ## 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. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap index fd8ef4dc98..8e1b90a1cf 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap @@ -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. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap index 0d172d756b..7ee1ab207f 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap @@ -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. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap index e80857d354..273e43d20b 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap @@ -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. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap index 576bf0ddd5..e23dc220b4 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap @@ -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. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap index e80857d354..273e43d20b 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap @@ -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. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap index 6fc7ad69ce..ad90a58850 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap @@ -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. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap index e80857d354..273e43d20b 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap @@ -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. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap index 6ccccedefe..005813848f 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap @@ -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. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap index fd8ef4dc98..8e1b90a1cf 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap @@ -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. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap index e80857d354..273e43d20b 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap @@ -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. diff --git a/src/core/prompts/__tests__/add-custom-instructions.spec.ts b/src/core/prompts/__tests__/add-custom-instructions.spec.ts index b2ca5589f9..5097685e3b 100644 --- a/src/core/prompts/__tests__/add-custom-instructions.spec.ts +++ b/src/core/prompts/__tests__/add-custom-instructions.spec.ts @@ -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, diff --git a/src/core/prompts/__tests__/system-prompt.spec.ts b/src/core/prompts/__tests__/system-prompt.spec.ts index e6af6eaf5a..7589790efe 100644 --- a/src/core/prompts/__tests__/system-prompt.spec.ts +++ b/src/core/prompts/__tests__/system-prompt.spec.ts @@ -79,7 +79,7 @@ __setMockImplementation( globalCustomInstructions: string, cwd: string, mode: string, - options?: { language?: string }, + options?: { language?: string; rooIgnoreInstructions?: string; settings?: Record }, ) => { 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() }) diff --git a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts index 9c8e003143..f6fb8ea1f4 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts @@ -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({ diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index 3c8558a57f..a78882cc90 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -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 { // 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 { +async function resolveSymLink( + symlinkPath: string, + fileInfo: Array<{ originalPath: string; resolvedPath: string }>, + depth: number, +): Promise { // 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[] = [] 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 = [] // Collect promises for the initial resolution calls const initialPromises: Promise[] = [] 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 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 } = {}, ): Promise { const sections = [] diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index bfc12930a0..ea4f43823e 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -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 diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index 3fd5a636a4..9f4af7f312 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -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] diff --git a/src/core/sliding-window/__tests__/sliding-window.spec.ts b/src/core/sliding-window/__tests__/sliding-window.spec.ts index 3bda5351d4..393d50307e 100644 --- a/src/core/sliding-window/__tests__/sliding-window.spec.ts +++ b/src/core/sliding-window/__tests__/sliding-window.spec.ts @@ -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 () => { diff --git a/src/core/sliding-window/index.ts b/src/core/sliding-window/index.ts index ae26f51a52..1e518c9a56 100644 --- a/src/core/sliding-window/index.ts +++ b/src/core/sliding-window/index.ts @@ -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] diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b8238689bf..6c6e950613 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1473,16 +1473,18 @@ export class Task extends EventEmitter { // 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) diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/executeCommandTool.ts index e895701721..81dc1993b2 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -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 }, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6224291a4f..094539e5df 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 85106ba2ca..4595e59f70 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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 diff --git a/src/i18n/locales/ca/embeddings.json b/src/i18n/locales/ca/embeddings.json index 5deed252bf..651bc2b80f 100644 --- a/src/i18n/locales/ca/embeddings.json +++ b/src/i18n/locales/ca/embeddings.json @@ -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ó.", diff --git a/src/i18n/locales/de/embeddings.json b/src/i18n/locales/de/embeddings.json index 74381747e1..167abc516c 100644 --- a/src/i18n/locales/de/embeddings.json +++ b/src/i18n/locales/de/embeddings.json @@ -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.", diff --git a/src/i18n/locales/en/embeddings.json b/src/i18n/locales/en/embeddings.json index 96b3b2dbea..270a8d193b 100644 --- a/src/i18n/locales/en/embeddings.json +++ b/src/i18n/locales/en/embeddings.json @@ -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.", diff --git a/src/i18n/locales/es/embeddings.json b/src/i18n/locales/es/embeddings.json index e47db420eb..06478f1d50 100644 --- a/src/i18n/locales/es/embeddings.json +++ b/src/i18n/locales/es/embeddings.json @@ -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.", diff --git a/src/i18n/locales/fr/embeddings.json b/src/i18n/locales/fr/embeddings.json index c63d3a7fbc..167d093e7a 100644 --- a/src/i18n/locales/fr/embeddings.json +++ b/src/i18n/locales/fr/embeddings.json @@ -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.", diff --git a/src/i18n/locales/hi/embeddings.json b/src/i18n/locales/hi/embeddings.json index 15709fd700..ad24cfe9d1 100644 --- a/src/i18n/locales/hi/embeddings.json +++ b/src/i18n/locales/hi/embeddings.json @@ -46,6 +46,7 @@ "ollamaConfigMissing": "एम्बेडर बनाने के लिए Ollama कॉन्फ़िगरेशन गायब है", "openAiCompatibleConfigMissing": "एम्बेडर बनाने के लिए OpenAI संगत कॉन्फ़िगरेशन गायब है", "geminiConfigMissing": "एम्बेडर बनाने के लिए Gemini कॉन्फ़िगरेशन गायब है", + "mistralConfigMissing": "एम्बेडर निर्माण के लिए मिस्ट्रल कॉन्फ़िगरेशन गायब है", "invalidEmbedderType": "अमान्य एम्बेडर प्रकार कॉन्फ़िगर किया गया: {{embedderProvider}}", "vectorDimensionNotDeterminedOpenAiCompatible": "प्रदाता '{{provider}}' के साथ मॉडल '{{modelId}}' के लिए वेक्टर आयाम निर्धारित नहीं कर सका। कृपया सुनिश्चित करें कि OpenAI-संगत प्रदाता सेटिंग्स में 'एम्बेडिंग आयाम' सही तरीके से सेट है।", "vectorDimensionNotDetermined": "प्रदाता '{{provider}}' के साथ मॉडल '{{modelId}}' के लिए वेक्टर आयाम निर्धारित नहीं कर सका। मॉडल प्रोफ़ाइल या कॉन्फ़िगरेशन की जांच करें।", diff --git a/src/i18n/locales/id/embeddings.json b/src/i18n/locales/id/embeddings.json index e78d39d1ab..997c6e8018 100644 --- a/src/i18n/locales/id/embeddings.json +++ b/src/i18n/locales/id/embeddings.json @@ -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.", diff --git a/src/i18n/locales/it/embeddings.json b/src/i18n/locales/it/embeddings.json index 679b17a25e..1bc406aecb 100644 --- a/src/i18n/locales/it/embeddings.json +++ b/src/i18n/locales/it/embeddings.json @@ -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.", diff --git a/src/i18n/locales/ja/embeddings.json b/src/i18n/locales/ja/embeddings.json index 89136eb1cc..7152eb52df 100644 --- a/src/i18n/locales/ja/embeddings.json +++ b/src/i18n/locales/ja/embeddings.json @@ -46,6 +46,7 @@ "ollamaConfigMissing": "エンベッダー作成のためのOllama設定がありません", "openAiCompatibleConfigMissing": "エンベッダー作成のためのOpenAI互換設定がありません", "geminiConfigMissing": "エンベッダー作成のためのGemini設定がありません", + "mistralConfigMissing": "エンベッダー作成のためのMistral設定がありません", "invalidEmbedderType": "無効なエンベッダータイプが設定されています: {{embedderProvider}}", "vectorDimensionNotDeterminedOpenAiCompatible": "プロバイダー '{{provider}}' のモデル '{{modelId}}' の埋め込み次元を決定できませんでした。OpenAI互換プロバイダー設定で「埋め込み次元」が正しく設定されていることを確認してください。", "vectorDimensionNotDetermined": "プロバイダー '{{provider}}' のモデル '{{modelId}}' の埋め込み次元を決定できませんでした。モデルプロファイルまたは設定を確認してください。", diff --git a/src/i18n/locales/ko/embeddings.json b/src/i18n/locales/ko/embeddings.json index 7129883ad7..f1c40f66bc 100644 --- a/src/i18n/locales/ko/embeddings.json +++ b/src/i18n/locales/ko/embeddings.json @@ -46,6 +46,7 @@ "ollamaConfigMissing": "임베더 생성을 위한 Ollama 구성이 누락되었습니다", "openAiCompatibleConfigMissing": "임베더 생성을 위한 OpenAI 호환 구성이 누락되었습니다", "geminiConfigMissing": "임베더 생성을 위한 Gemini 구성이 누락되었습니다", + "mistralConfigMissing": "임베더 생성을 위한 Mistral 구성이 없습니다", "invalidEmbedderType": "잘못된 임베더 유형이 구성되었습니다: {{embedderProvider}}", "vectorDimensionNotDeterminedOpenAiCompatible": "프로바이더 '{{provider}}'의 모델 '{{modelId}}'에 대한 벡터 차원을 결정할 수 없습니다. OpenAI 호환 프로바이더 설정에서 '임베딩 차원'이 올바르게 설정되어 있는지 확인하세요.", "vectorDimensionNotDetermined": "프로바이더 '{{provider}}'의 모델 '{{modelId}}'에 대한 벡터 차원을 결정할 수 없습니다. 모델 프로필 또는 구성을 확인하세요.", diff --git a/src/i18n/locales/nl/embeddings.json b/src/i18n/locales/nl/embeddings.json index ede20774ac..19b7bfeaa2 100644 --- a/src/i18n/locales/nl/embeddings.json +++ b/src/i18n/locales/nl/embeddings.json @@ -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.", diff --git a/src/i18n/locales/pl/embeddings.json b/src/i18n/locales/pl/embeddings.json index 70279021bd..46e761cb8b 100644 --- a/src/i18n/locales/pl/embeddings.json +++ b/src/i18n/locales/pl/embeddings.json @@ -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ę.", diff --git a/src/i18n/locales/pt-BR/embeddings.json b/src/i18n/locales/pt-BR/embeddings.json index aea1bb5007..816b1ecded 100644 --- a/src/i18n/locales/pt-BR/embeddings.json +++ b/src/i18n/locales/pt-BR/embeddings.json @@ -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.", diff --git a/src/i18n/locales/ru/embeddings.json b/src/i18n/locales/ru/embeddings.json index a724539b76..fb1688e2ca 100644 --- a/src/i18n/locales/ru/embeddings.json +++ b/src/i18n/locales/ru/embeddings.json @@ -46,6 +46,7 @@ "ollamaConfigMissing": "Отсутствует конфигурация Ollama для создания эмбеддера", "openAiCompatibleConfigMissing": "Отсутствует конфигурация, совместимая с OpenAI, для создания эмбеддера", "geminiConfigMissing": "Отсутствует конфигурация Gemini для создания эмбеддера", + "mistralConfigMissing": "Конфигурация Mistral отсутствует для создания эмбеддера", "invalidEmbedderType": "Настроен недопустимый тип эмбеддера: {{embedderProvider}}", "vectorDimensionNotDeterminedOpenAiCompatible": "Не удалось определить размерность вектора для модели '{{modelId}}' с провайдером '{{provider}}'. Убедитесь, что 'Размерность эмбеддинга' правильно установлена в настройках провайдера, совместимого с OpenAI.", "vectorDimensionNotDetermined": "Не удалось определить размерность вектора для модели '{{modelId}}' с провайдером '{{provider}}'. Проверьте профили модели или конфигурацию.", diff --git a/src/i18n/locales/tr/embeddings.json b/src/i18n/locales/tr/embeddings.json index 3e115ce103..5023190929 100644 --- a/src/i18n/locales/tr/embeddings.json +++ b/src/i18n/locales/tr/embeddings.json @@ -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.", diff --git a/src/i18n/locales/vi/embeddings.json b/src/i18n/locales/vi/embeddings.json index 9ef61105fa..626f0f6862 100644 --- a/src/i18n/locales/vi/embeddings.json +++ b/src/i18n/locales/vi/embeddings.json @@ -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.", diff --git a/src/i18n/locales/zh-CN/embeddings.json b/src/i18n/locales/zh-CN/embeddings.json index d3ded6e5a2..3247631bb2 100644 --- a/src/i18n/locales/zh-CN/embeddings.json +++ b/src/i18n/locales/zh-CN/embeddings.json @@ -46,6 +46,7 @@ "ollamaConfigMissing": "创建嵌入器缺少 Ollama 配置", "openAiCompatibleConfigMissing": "创建嵌入器缺少 OpenAI 兼容配置", "geminiConfigMissing": "创建嵌入器缺少 Gemini 配置", + "mistralConfigMissing": "创建嵌入器时缺少 Mistral 配置", "invalidEmbedderType": "配置的嵌入器类型无效:{{embedderProvider}}", "vectorDimensionNotDeterminedOpenAiCompatible": "无法确定提供商 '{{provider}}' 的模型 '{{modelId}}' 的向量维度。请确保在 OpenAI 兼容提供商设置中正确设置了「嵌入维度」。", "vectorDimensionNotDetermined": "无法确定提供商 '{{provider}}' 的模型 '{{modelId}}' 的向量维度。请检查模型配置文件或配置。", diff --git a/src/i18n/locales/zh-TW/embeddings.json b/src/i18n/locales/zh-TW/embeddings.json index 5ab5dcb292..b3b3231d4a 100644 --- a/src/i18n/locales/zh-TW/embeddings.json +++ b/src/i18n/locales/zh-TW/embeddings.json @@ -46,6 +46,7 @@ "ollamaConfigMissing": "建立嵌入器缺少 Ollama 設定", "openAiCompatibleConfigMissing": "建立嵌入器缺少 OpenAI 相容設定", "geminiConfigMissing": "建立嵌入器缺少 Gemini 設定", + "mistralConfigMissing": "建立嵌入器時缺少 Mistral 設定", "invalidEmbedderType": "設定的嵌入器類型無效:{{embedderProvider}}", "vectorDimensionNotDeterminedOpenAiCompatible": "無法確定提供商 '{{provider}}' 的模型 '{{modelId}}' 的向量維度。請確保在 OpenAI 相容提供商設定中正確設定了「嵌入維度」。", "vectorDimensionNotDetermined": "無法確定提供商 '{{provider}}' 的模型 '{{modelId}}' 的向量維度。請檢查模型設定檔或設定。", diff --git a/src/integrations/misc/__tests__/extract-text.spec.ts b/src/integrations/misc/__tests__/extract-text.spec.ts index 04b06cfa83..bb4b52fe93 100644 --- a/src/integrations/misc/__tests__/extract-text.spec.ts +++ b/src/integrations/misc/__tests__/extract-text.spec.ts @@ -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", () => { diff --git a/src/integrations/misc/extract-text.ts b/src/integrations/misc/extract-text.ts index 8c7e7408a6..eb02f63b95 100644 --- a/src/integrations/misc/extract-text.ts +++ b/src/integrations/misc/extract-text.ts @@ -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 } diff --git a/src/integrations/terminal/BaseTerminal.ts b/src/integrations/terminal/BaseTerminal.ts index 8137881b8c..a79d417b07 100644 --- a/src/integrations/terminal/BaseTerminal.ts +++ b/src/integrations/terminal/BaseTerminal.ts @@ -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) } /** diff --git a/src/package.json b/src/package.json index 916a109088..5e3cd3bc53 100644 --- a/src/package.json +++ b/src/package.json @@ -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", diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index 9958f456c3..1723f1c2a0 100644 --- a/src/services/code-index/config-manager.ts +++ b/src/services/code-index/config-manager.ts @@ -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, diff --git a/src/services/code-index/constants/index.ts b/src/services/code-index/constants/index.ts index 706a73935a..6f0e0fe7e6 100644 --- a/src/services/code-index/constants/index.ts +++ b/src/services/code-index/constants/index.ts @@ -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 diff --git a/src/services/code-index/embedders/__tests__/mistral.spec.ts b/src/services/code-index/embedders/__tests__/mistral.spec.ts new file mode 100644 index 0000000000..5085882503 --- /dev/null +++ b/src/services/code-index/embedders/__tests__/mistral.spec.ts @@ -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 + +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") + }) + }) +}) diff --git a/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts b/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts new file mode 100644 index 0000000000..3e2acc398e --- /dev/null +++ b/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts @@ -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) => { + const translations: Record = { + "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 + +describe("OpenAICompatibleEmbedder - Global Rate Limiting", () => { + let mockOpenAIInstance: any + let mockEmbeddingsCreate: MockedFunction + + 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) + }) +}) diff --git a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts index ff757b86c7..0353771f60 100644 --- a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts @@ -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() diff --git a/src/services/code-index/embedders/mistral.ts b/src/services/code-index/embedders/mistral.ts new file mode 100644 index 0000000000..c23bcbba1d --- /dev/null +++ b/src/services/code-index/embedders/mistral.ts @@ -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 { + 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", + } + } +} diff --git a/src/services/code-index/embedders/openai-compatible.ts b/src/services/code-index/embedders/openai-compatible.ts index d882e78313..035f50f386 100644 --- a/src/services/code-index/embedders/openai-compatible.ts +++ b/src/services/code-index/embedders/openai-compatible.ts @@ -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 { + 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 { + 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 { + 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() + } + } } diff --git a/src/services/code-index/interfaces/config.ts b/src/services/code-index/interfaces/config.ts index 190a23e2a3..9098a60091 100644 --- a/src/services/code-index/interfaces/config.ts +++ b/src/services/code-index/interfaces/config.ts @@ -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 } diff --git a/src/services/code-index/interfaces/embedder.ts b/src/services/code-index/interfaces/embedder.ts index 0a74446d5e..c5653ea2b7 100644 --- a/src/services/code-index/interfaces/embedder.ts +++ b/src/services/code-index/interfaces/embedder.ts @@ -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 diff --git a/src/services/code-index/interfaces/manager.ts b/src/services/code-index/interfaces/manager.ts index 70e3fd9765..fd3b2bfdda 100644 --- a/src/services/code-index/interfaces/manager.ts +++ b/src/services/code-index/interfaces/manager.ts @@ -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 diff --git a/src/services/code-index/processors/scanner.ts b/src/services/code-index/processors/scanner.ts index e6ca297399..3203076d12 100644 --- a/src/services/code-index/processors/scanner.ts +++ b/src/services/code-index/processors/scanner.ts @@ -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>() + 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() diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index b7951db7ac..68b0f5c0bc 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -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( diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index 98ef4514c2..7dc7f00c04 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -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: "", diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index e2b2e4e1d9..e15135e5ef 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 11a8b437a5..a620faa60b 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -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 } } diff --git a/src/shared/__tests__/checkExistApiConfig.spec.ts b/src/shared/__tests__/checkExistApiConfig.spec.ts index 7bc9e1d576..7696f00cc0 100644 --- a/src/shared/__tests__/checkExistApiConfig.spec.ts +++ b/src/shared/__tests__/checkExistApiConfig.spec.ts @@ -53,6 +53,7 @@ describe("checkExistKey", () => { geminiApiKey: undefined, openAiNativeApiKey: undefined, deepSeekApiKey: undefined, + moonshotApiKey: undefined, mistralApiKey: undefined, vsCodeLmModelSelector: undefined, requestyApiKey: undefined, diff --git a/src/shared/embeddingModels.ts b/src/shared/embeddingModels.ts index f387480c65..a3cd61e659 100644 --- a/src/shared/embeddingModels.ts +++ b/src/shared/embeddingModels.ts @@ -2,7 +2,7 @@ * Defines profiles for different embedding models, including their dimensions. */ -export type EmbedderProvider = "openai" | "ollama" | "openai-compatible" | "gemini" // Add other providers as needed +export type EmbedderProvider = "openai" | "ollama" | "openai-compatible" | "gemini" | "mistral" // Add other providers as needed export interface EmbeddingModelProfile { dimension: number @@ -50,6 +50,9 @@ export const EMBEDDING_MODEL_PROFILES: EmbeddingModelProfiles = { "text-embedding-004": { dimension: 768 }, "gemini-embedding-001": { dimension: 3072, scoreThreshold: 0.4 }, }, + mistral: { + "codestral-embed-2505": { dimension: 1536, scoreThreshold: 0.4 }, + }, } /** @@ -137,6 +140,9 @@ export function getDefaultModelId(provider: EmbedderProvider): string { case "gemini": return "gemini-embedding-001" + case "mistral": + return "codestral-embed-2505" + default: // Fallback for unknown providers console.warn(`Unknown provider for default model ID: ${provider}. Falling back to OpenAI default.`) diff --git a/src/shared/modes.ts b/src/shared/modes.ts index 9c790236eb..168f4ff867 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -72,7 +72,7 @@ export const modes: readonly ModeConfig[] = [ description: "Plan and design before implementation", groups: ["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], "browser", "mcp"], customInstructions: - "1. Do some information gathering (using provided tools) to get more context about the task.\n\n2. You should also ask the user clarifying questions to get a better understanding of the task.\n\n3. Once you've gained more context about the user's request, break down the task into clear, actionable steps and create a todo list using the `update_todo_list` tool. Each todo item should be:\n - Specific and actionable\n - Listed in logical execution order\n - Focused on a single, well-defined outcome\n - Clear enough that another mode could execute it independently\n\n4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.\n\n5. 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.\n\n6. Include Mermaid diagrams if they help clarify complex workflows or system architecture. Please avoid using double quotes (\"\") and parentheses () inside square brackets ([]) in Mermaid diagrams, as this can cause parsing errors.\n\n7. Use the switch_mode tool to request that the user switch to another mode to implement the solution.\n\n**IMPORTANT: Focus on creating clear, actionable todo lists rather than lengthy markdown documents. Use the todo list as your primary planning tool to track and organize the work that needs to be done.**", + "1. Do some information gathering (using provided tools) to get more context about the task.\n\n2. You should also ask the user clarifying questions to get a better understanding of the task.\n\n3. Once you've gained more context about the user's request, break down the task into clear, actionable steps and create a todo list using the `update_todo_list` tool. Each todo item should be:\n - Specific and actionable\n - Listed in logical execution order\n - Focused on a single, well-defined outcome\n - Clear enough that another mode could execute it independently\n\n **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.\n\n4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.\n\n5. 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.\n\n6. Include Mermaid diagrams if they help clarify complex workflows or system architecture. Please avoid using double quotes (\"\") and parentheses () inside square brackets ([]) in Mermaid diagrams, as this can cause parsing errors.\n\n7. Use the switch_mode tool to request that the user switch to another mode to implement the solution.\n\n**IMPORTANT: Focus on creating clear, actionable todo lists rather than lengthy markdown documents. Use the todo list as your primary planning tool to track and organize the work that needs to be done.**", }, { slug: "code", diff --git a/src/shared/support-prompt.ts b/src/shared/support-prompt.ts index 1767a20753..51f4310fc2 100644 --- a/src/shared/support-prompt.ts +++ b/src/shared/support-prompt.ts @@ -35,6 +35,7 @@ interface SupportPromptConfig { type SupportPromptType = | "ENHANCE" + | "CONDENSE" | "EXPLAIN" | "FIX" | "IMPROVE" @@ -49,6 +50,45 @@ const supportPromptConfigs: Record = { template: `Generate an enhanced version of this prompt (reply with only the enhanced prompt - no conversation, explanations, lead-in, bullet points, placeholders, or surrounding quotes): \${userInput}`, + }, + CONDENSE: { + template: `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. +This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing with the conversation and supporting any continuing tasks. + +Your summary should be structured as follows: +Context: The context to continue the conversation with. If applicable based on the current task, this should include: + 1. Previous Conversation: High level details about what was discussed throughout the entire conversation with the user. This should be written to allow someone to be able to follow the general overarching conversation flow. + 2. Current Work: Describe in detail what was being worked on prior to this request to summarize the conversation. Pay special attention to the more recent messages in the conversation. + 3. Key Technical Concepts: List all important technical concepts, technologies, coding conventions, and frameworks discussed, which might be relevant for continuing with this work. + 4. Relevant Files and Code: If applicable, enumerate specific files and code sections examined, modified, or created for the task continuation. Pay special attention to the most recent messages and changes. + 5. Problem Solving: Document problems solved thus far and any ongoing troubleshooting efforts. + 6. Pending Tasks and Next Steps: Outline all pending tasks that you have explicitly been asked to work on, as well as list the next steps you will take for all outstanding work, if applicable. Include code snippets where they add clarity. For any next steps, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no information loss in context between tasks. + +Example summary structure: +1. Previous Conversation: + [Detailed description] +2. Current Work: + [Detailed description] +3. Key Technical Concepts: + - [Concept 1] + - [Concept 2] + - [...] +4. Relevant Files and Code: + - [File Name 1] + - [Summary of why this file is important] + - [Summary of the changes made to this file, if any] + - [Important Code Snippet] + - [File Name 2] + - [Important Code Snippet] + - [...] +5. Problem Solving: + [Detailed description] +6. Pending Tasks and Next Steps: + - [Task 1 details & next steps] + - [Task 2 details & next steps] + - [...] + +Output only the summary of the conversation so far, without any additional commentary or explanation.`, }, EXPLAIN: { template: `Explain the following code from file path \${filePath}:\${startLine}-\${endLine} diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 12eac7373f..f20eec1c23 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -533,6 +533,7 @@ export const ChatRowContent = ({ isLoading={message.partial} isExpanded={isExpanded} onToggleExpand={handleToggleExpand} + onJumpToFile={() => vscode.postMessage({ type: "openFile", text: "./" + tool.path })} /> ) diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 4385e2e844..d7683e8c7e 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -68,6 +68,7 @@ interface LocalCodeIndexSettings { codebaseIndexOpenAiCompatibleBaseUrl?: string codebaseIndexOpenAiCompatibleApiKey?: string codebaseIndexGeminiApiKey?: string + codebaseIndexMistralApiKey?: string } // Validation schema for codebase index settings @@ -126,6 +127,14 @@ const createValidationSchema = (provider: EmbedderProvider, t: any) => { .min(1, t("settings:codeIndex.validation.modelSelectionRequired")), }) + case "mistral": + return baseSchema.extend({ + codebaseIndexMistralApiKey: z.string().min(1, t("settings:codeIndex.validation.mistralApiKeyRequired")), + codebaseIndexEmbedderModelId: z + .string() + .min(1, t("settings:codeIndex.validation.modelSelectionRequired")), + }) + default: return baseSchema } @@ -169,6 +178,7 @@ export const CodeIndexPopover: React.FC = ({ codebaseIndexOpenAiCompatibleBaseUrl: "", codebaseIndexOpenAiCompatibleApiKey: "", codebaseIndexGeminiApiKey: "", + codebaseIndexMistralApiKey: "", }) // Initial settings state - stores the settings when popover opens @@ -202,6 +212,7 @@ export const CodeIndexPopover: React.FC = ({ codebaseIndexOpenAiCompatibleBaseUrl: codebaseIndexConfig.codebaseIndexOpenAiCompatibleBaseUrl || "", codebaseIndexOpenAiCompatibleApiKey: "", codebaseIndexGeminiApiKey: "", + codebaseIndexMistralApiKey: "", } setInitialSettings(settings) setCurrentSettings(settings) @@ -293,6 +304,9 @@ export const CodeIndexPopover: React.FC = ({ if (!prev.codebaseIndexGeminiApiKey || prev.codebaseIndexGeminiApiKey === SECRET_PLACEHOLDER) { updated.codebaseIndexGeminiApiKey = secretStatus.hasGeminiApiKey ? SECRET_PLACEHOLDER : "" } + if (!prev.codebaseIndexMistralApiKey || prev.codebaseIndexMistralApiKey === SECRET_PLACEHOLDER) { + updated.codebaseIndexMistralApiKey = secretStatus.hasMistralApiKey ? SECRET_PLACEHOLDER : "" + } return updated } @@ -364,7 +378,8 @@ export const CodeIndexPopover: React.FC = ({ if ( key === "codeIndexOpenAiKey" || key === "codebaseIndexOpenAiCompatibleApiKey" || - key === "codebaseIndexGeminiApiKey" + key === "codebaseIndexGeminiApiKey" || + key === "codebaseIndexMistralApiKey" ) { dataToValidate[key] = "placeholder-valid" } @@ -606,6 +621,9 @@ export const CodeIndexPopover: React.FC = ({ {t("settings:codeIndex.geminiProvider")} + + {t("settings:codeIndex.mistralProvider")} + @@ -933,6 +951,71 @@ export const CodeIndexPopover: React.FC = ({ )} + {currentSettings.codebaseIndexEmbedderProvider === "mistral" && ( + <> +
+ + + updateSetting("codebaseIndexMistralApiKey", e.target.value) + } + placeholder={t("settings:codeIndex.mistralApiKeyPlaceholder")} + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexMistralApiKey, + })} + /> + {formErrors.codebaseIndexMistralApiKey && ( +

+ {formErrors.codebaseIndexMistralApiKey} +

+ )} +
+ +
+ + + updateSetting("codebaseIndexEmbedderModelId", e.target.value) + } + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexEmbedderModelId, + })}> + + {t("settings:codeIndex.selectModel")} + + {getAvailableModels().map((modelId) => { + const model = + codebaseIndexModels?.[ + currentSettings.codebaseIndexEmbedderProvider + ]?.[modelId] + return ( + + {modelId}{" "} + {model + ? t("settings:codeIndex.modelDimensions", { + dimension: model.dimension, + }) + : ""} + + ) + })} + + {formErrors.codebaseIndexEmbedderModelId && ( +

+ {formErrors.codebaseIndexEmbedderModelId} +

+ )} +
+ + )} + {/* Qdrant Settings */}
- - {/* API Configuration Selection */} -
-
- -
{t("settings:contextManagement.condensingApiConfiguration.label")}
-
-
-
- {t("settings:contextManagement.condensingApiConfiguration.description")} -
- -
-
- - {/* Custom Prompt Section */} -
-
- -
{t("settings:contextManagement.customCondensingPrompt.label")}
-
-
-
- {t("settings:contextManagement.customCondensingPrompt.description")} -
- { - const value = (e.target as HTMLTextAreaElement).value - setCachedStateField("customCondensingPrompt", value) - vscode.postMessage({ - type: "updateCondensingPrompt", - text: value, - }) - }} - rows={8} - className="w-full font-mono text-sm" - /> -
- -
-
-
)} diff --git a/webview-ui/src/components/settings/PromptsSettings.tsx b/webview-ui/src/components/settings/PromptsSettings.tsx index 160f79dc84..a71132d62b 100644 --- a/webview-ui/src/components/settings/PromptsSettings.tsx +++ b/webview-ui/src/components/settings/PromptsSettings.tsx @@ -26,7 +26,15 @@ interface PromptsSettingsProps { const PromptsSettings = ({ customSupportPrompts, setCustomSupportPrompts }: PromptsSettingsProps) => { const { t } = useAppTranslation() - const { listApiConfigMeta, enhancementApiConfigId, setEnhancementApiConfigId } = useExtensionState() + const { + listApiConfigMeta, + enhancementApiConfigId, + setEnhancementApiConfigId, + condensingApiConfigId, + setCondensingApiConfigId, + customCondensingPrompt, + setCustomCondensingPrompt, + } = useExtensionState() const [testPrompt, setTestPrompt] = useState("") const [isEnhancing, setIsEnhancing] = useState(false) @@ -48,17 +56,36 @@ const PromptsSettings = ({ customSupportPrompts, setCustomSupportPrompts }: Prom }, []) const updateSupportPrompt = (type: SupportPromptType, value: string | undefined) => { - const updatedPrompts = { ...customSupportPrompts, [type]: value } - setCustomSupportPrompts(updatedPrompts) + if (type === "CONDENSE") { + setCustomCondensingPrompt(value || supportPrompt.default.CONDENSE) + vscode.postMessage({ + type: "updateCondensingPrompt", + text: value || supportPrompt.default.CONDENSE, + }) + } else { + const updatedPrompts = { ...customSupportPrompts, [type]: value } + setCustomSupportPrompts(updatedPrompts) + } } const handleSupportReset = (type: SupportPromptType) => { - const updatedPrompts = { ...customSupportPrompts } - delete updatedPrompts[type] - setCustomSupportPrompts(updatedPrompts) + if (type === "CONDENSE") { + setCustomCondensingPrompt(supportPrompt.default.CONDENSE) + vscode.postMessage({ + type: "updateCondensingPrompt", + text: supportPrompt.default.CONDENSE, + }) + } else { + const updatedPrompts = { ...customSupportPrompts } + delete updatedPrompts[type] + setCustomSupportPrompts(updatedPrompts) + } } const getSupportPromptValue = (type: SupportPromptType): string => { + if (type === "CONDENSE") { + return customCondensingPrompt || supportPrompt.default.CONDENSE + } return supportPrompt.get(customSupportPrompts, type) } @@ -129,29 +156,50 @@ const PromptsSettings = ({ customSupportPrompts, setCustomSupportPrompts }: Prom className="w-full" /> - {activeSupportOption === "ENHANCE" && ( + {(activeSupportOption === "ENHANCE" || activeSupportOption === "CONDENSE") && (
- {t("prompts:supportPrompts.enhance.apiConfigDescription")} + {activeSupportOption === "ENHANCE" + ? t("prompts:supportPrompts.enhance.apiConfigDescription") + : t("prompts:supportPrompts.condense.apiConfigDescription")}
-
- - setTestPrompt((e.target as HTMLTextAreaElement).value)} - placeholder={t("prompts:supportPrompts.enhance.testPromptPlaceholder")} - rows={3} - className="w-full" - data-testid="test-prompt-textarea" - /> -
- + {activeSupportOption === "ENHANCE" && ( +
+ + setTestPrompt((e.target as HTMLTextAreaElement).value)} + placeholder={t("prompts:supportPrompts.enhance.testPromptPlaceholder")} + rows={3} + className="w-full" + data-testid="test-prompt-textarea" + /> +
+ +
-
+ )}
)}
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index fd3a8a129b..517c1c159d 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -155,6 +155,7 @@ const SettingsView = forwardRef(({ onDone, t soundVolume, telemetrySetting, terminalOutputLineLimit, + terminalOutputCharacterLimit, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, // Added from upstream terminalCommandDelay, @@ -301,6 +302,7 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs }) vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 }) vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 }) + vscode.postMessage({ type: "terminalOutputCharacterLimit", value: terminalOutputCharacterLimit ?? 50000 }) vscode.postMessage({ type: "terminalShellIntegrationTimeout", value: terminalShellIntegrationTimeout }) vscode.postMessage({ type: "terminalShellIntegrationDisabled", bool: terminalShellIntegrationDisabled }) vscode.postMessage({ type: "terminalCommandDelay", value: terminalCommandDelay }) @@ -657,8 +659,6 @@ const SettingsView = forwardRef(({ onDone, t (({ onDone, t {activeTab === "terminal" && ( & { terminalOutputLineLimit?: number + terminalOutputCharacterLimit?: number terminalShellIntegrationTimeout?: number terminalShellIntegrationDisabled?: boolean terminalCommandDelay?: number @@ -29,6 +30,7 @@ type TerminalSettingsProps = HTMLAttributes & { terminalCompressProgressBar?: boolean setCachedStateField: SetCachedStateField< | "terminalOutputLineLimit" + | "terminalOutputCharacterLimit" | "terminalShellIntegrationTimeout" | "terminalShellIntegrationDisabled" | "terminalCommandDelay" @@ -43,6 +45,7 @@ type TerminalSettingsProps = HTMLAttributes & { export const TerminalSettings = ({ terminalOutputLineLimit, + terminalOutputCharacterLimit, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, terminalCommandDelay, @@ -129,6 +132,36 @@ export const TerminalSettings = ({ +
+ +
+ + setCachedStateField("terminalOutputCharacterLimit", value) + } + data-testid="terminal-output-character-limit-slider" + /> + {terminalOutputCharacterLimit ?? 50000} +
+
+ + + {" "} + + +
+
void +} + +export const TodoListSettingsControl: React.FC = ({ + todoListEnabled = true, + onChange, +}) => { + const { t } = useAppTranslation() + + const handleTodoListEnabledChange = useCallback( + (e: any) => { + onChange("todoListEnabled", e.target.checked) + }, + [onChange], + ) + + return ( +
+
+ + {t("settings:advanced.todoList.label")} + +
+ {t("settings:advanced.todoList.description")} +
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx index bf02840429..7b7f9b33e4 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx @@ -21,6 +21,16 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeRadio: ({ value, checked }: any) => , VSCodeRadioGroup: ({ children }: any) =>
{children}
, VSCodeButton: ({ children }: any) =>
{children}
, + VSCodeCheckbox: ({ children, checked, onChange }: any) => ( + + ), })) // Mock other components @@ -173,6 +183,22 @@ vi.mock("../DiffSettingsControl", () => ({ ), })) +// Mock TodoListSettingsControl for tests +vi.mock("../TodoListSettingsControl", () => ({ + TodoListSettingsControl: ({ todoListEnabled, onChange }: any) => ( +
+ +
+ ), +})) + // Mock ThinkingBudget component vi.mock("../ThinkingBudget", () => ({ ThinkingBudget: ({ modelInfo }: any) => { diff --git a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx index e467ab51d6..5db0597052 100644 --- a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx @@ -10,7 +10,6 @@ vitest.mock("@/i18n/TranslationContext", () => ({ })) // Mock vscode utilities - this is necessary since we're not in a VSCode environment -import { vscode } from "@/utils/vscode" vitest.mock("@/utils/vscode", () => ({ vscode: { @@ -169,8 +168,6 @@ describe("ContextManagementSettings", () => { ...defaultProps, autoCondenseContext: true, autoCondenseContextPercent: 75, - condensingApiConfigId: "test-config", - customCondensingPrompt: "Test prompt", } render() @@ -182,13 +179,9 @@ describe("ContextManagementSettings", () => { const slider = screen.getByTestId("condense-threshold-slider") expect(slider).toBeInTheDocument() - // Should render both select dropdowns (profile and API config) + // Should render the profile select dropdown const selects = screen.getAllByRole("combobox") - expect(selects).toHaveLength(2) - - // Should render the custom prompt textarea - const textarea = screen.getByRole("textbox") - expect(textarea).toBeInTheDocument() + expect(selects).toHaveLength(1) }) describe("Auto Condense Context functionality", () => { @@ -196,8 +189,6 @@ describe("ContextManagementSettings", () => { ...defaultProps, autoCondenseContext: true, autoCondenseContextPercent: 75, - condensingApiConfigId: "test-config", - customCondensingPrompt: "Custom test prompt", listApiConfigMeta: [ { id: "config-1", name: "Config 1" }, { id: "config-2", name: "Config 2" }, @@ -217,14 +208,13 @@ describe("ContextManagementSettings", () => { expect(mockSetCachedStateField).toHaveBeenCalledWith("autoCondenseContext", false) }) - it("shows additional settings when auto condense is enabled", () => { + it("shows threshold settings when auto condense is enabled", () => { render() - // Additional settings should be visible + // Threshold settings should be visible expect(screen.getByTestId("condense-threshold-slider")).toBeInTheDocument() - // Two comboboxes: one for profile selection, one for API config - expect(screen.getAllByRole("combobox")).toHaveLength(2) - expect(screen.getByRole("textbox")).toBeInTheDocument() + // One combobox for profile selection + expect(screen.getAllByRole("combobox")).toHaveLength(1) }) it("updates auto condense context percent", () => { @@ -246,112 +236,6 @@ describe("ContextManagementSettings", () => { render() expect(screen.getByText("75%")).toBeInTheDocument() }) - - it("updates condensing API configuration", () => { - const mockSetCachedStateField = vitest.fn() - const mockPostMessage = vitest.fn() - const postMessageSpy = vitest.spyOn(vscode, "postMessage") - postMessageSpy.mockImplementation(mockPostMessage) - - const props = { ...autoCondenseProps, setCachedStateField: mockSetCachedStateField } - render() - - // Get the second combobox (API config select) - const selects = screen.getAllByRole("combobox") - const apiSelect = selects[1] - fireEvent.click(apiSelect) - - const configOption = screen.getByText("Config 1") - fireEvent.click(configOption) - - expect(mockSetCachedStateField).toHaveBeenCalledWith("condensingApiConfigId", "config-1") - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "condensingApiConfigId", - text: "config-1", - }) - }) - - it("handles selecting default config option", () => { - const mockSetCachedStateField = vitest.fn() - const mockPostMessage = vitest.fn() - const postMessageSpy = vitest.spyOn(vscode, "postMessage") - postMessageSpy.mockImplementation(mockPostMessage) - - const props = { ...autoCondenseProps, setCachedStateField: mockSetCachedStateField } - render() - - // Test selecting default config - get the second combobox (API config) - const selects = screen.getAllByRole("combobox") - const apiSelect = selects[1] - fireEvent.click(apiSelect) - const defaultOption = screen.getByText( - "settings:contextManagement.condensingApiConfiguration.useCurrentConfig", - ) - fireEvent.click(defaultOption) - - expect(mockSetCachedStateField).toHaveBeenCalledWith("condensingApiConfigId", "") - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "condensingApiConfigId", - text: "", - }) - }) - - it("updates custom condensing prompt", () => { - const mockSetCachedStateField = vitest.fn() - const mockPostMessage = vitest.fn() - const postMessageSpy = vitest.spyOn(vscode, "postMessage") - postMessageSpy.mockImplementation(mockPostMessage) - - const props = { ...autoCondenseProps, setCachedStateField: mockSetCachedStateField } - render() - - const textarea = screen.getByRole("textbox") - const newPrompt = "Updated custom prompt" - fireEvent.change(textarea, { target: { value: newPrompt } }) - - expect(mockSetCachedStateField).toHaveBeenCalledWith("customCondensingPrompt", newPrompt) - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "updateCondensingPrompt", - text: newPrompt, - }) - }) - - it("resets custom condensing prompt to default", () => { - const mockSetCachedStateField = vitest.fn() - const mockPostMessage = vitest.fn() - const postMessageSpy = vitest.spyOn(vscode, "postMessage") - postMessageSpy.mockImplementation(mockPostMessage) - - const props = { ...autoCondenseProps, setCachedStateField: mockSetCachedStateField } - render() - - const resetButton = screen.getByRole("button", { - name: "settings:contextManagement.customCondensingPrompt.reset", - }) - fireEvent.click(resetButton) - - // Should reset to the default SUMMARY_PROMPT - expect(mockSetCachedStateField).toHaveBeenCalledWith( - "customCondensingPrompt", - expect.stringContaining("Your task is to create a detailed summary"), - ) - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "updateCondensingPrompt", - text: expect.stringContaining("Your task is to create a detailed summary"), - }) - }) - - it("uses default prompt when customCondensingPrompt is undefined", () => { - const propsWithoutCustomPrompt = { - ...autoCondenseProps, - customCondensingPrompt: undefined, - } - render() - - const textarea = screen.getByRole("textbox") as HTMLTextAreaElement - // The textarea should contain the full default SUMMARY_PROMPT - expect(textarea.value).toContain("Your task is to create a detailed summary") - }) }) describe("Edge cases and validation", () => { @@ -427,8 +311,6 @@ describe("ContextManagementSettings", () => { ...defaultProps, showRooIgnoredFiles: undefined, maxReadFileLine: undefined, - condensingApiConfigId: undefined, - customCondensingPrompt: undefined, } expect(() => { @@ -442,21 +324,15 @@ describe("ContextManagementSettings", () => { }) describe("Conditional rendering", () => { - it("does not render auto condense section when autoCondenseContext is false", () => { + it("does not render threshold settings when autoCondenseContext is false", () => { const propsWithoutAutoCondense = { ...defaultProps, autoCondenseContext: false, } render() - // When auto condense is false, all condensing-related UI should not be visible + // When auto condense is false, threshold slider should not be visible expect(screen.queryByTestId("condense-threshold-slider")).not.toBeInTheDocument() - expect( - screen.queryByText("settings:contextManagement.condensingApiConfiguration.label"), - ).not.toBeInTheDocument() - expect( - screen.queryByText("settings:contextManagement.customCondensingPrompt.label"), - ).not.toBeInTheDocument() }) it("renders max read file controls with default value when maxReadFileLine is undefined", () => { diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index 72d4598ea6..694ff174a7 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -58,6 +58,16 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ ), VSCodeRadioGroup: ({ children, onChange }: any) =>
{children}
, + VSCodeTextArea: ({ value, onChange, rows, className, "data-testid": dataTestId }: any) => ( +