diff --git a/.changeset/config.json b/.changeset/config.json index e2acc37662..310bc51094 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,5 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": ["@roo-code/cli"] } diff --git a/.dockerignore b/.dockerignore index 6359978833..4579d61580 100644 --- a/.dockerignore +++ b/.dockerignore @@ -76,14 +76,18 @@ src/node_modules !pnpm-workspace.yaml !scripts/bootstrap.mjs !apps/web-evals/ +!apps/cli/ !src/ !webview-ui/ !packages/evals/.docker/entrypoints/runner.sh !packages/build/ !packages/config-eslint/ !packages/config-typescript/ +!packages/core/ !packages/evals/ !packages/ipc/ !packages/telemetry/ !packages/types/ +!packages/vscode-shim/ +!packages/cloud/ !locales/ diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 16e0c17e7a..443842c856 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -84,7 +84,6 @@ body: - Google Gemini - Google Vertex AI - Groq - - Human Relay Provider - LiteLLM - LM Studio - Mistral AI diff --git a/.roo/commands/release.md b/.roo/commands/release.md index 707844cdc4..2e09783a58 100644 --- a/.roo/commands/release.md +++ b/.roo/commands/release.md @@ -1,6 +1,7 @@ --- description: "Create a new release of the Roo Code extension" argument-hint: patch | minor | major +mode: code --- 1. Identify the SHA corresponding to the most recent release using GitHub CLI: `gh release view --json tagName,targetCommitish,publishedAt` diff --git a/.roo/rules-translate/instructions-zh-cn.md b/.roo/rules-translate/instructions-zh-cn.md index 6141038728..b166a1e6a8 100644 --- a/.roo/rules-translate/instructions-zh-cn.md +++ b/.roo/rules-translate/instructions-zh-cn.md @@ -16,7 +16,6 @@ | Auto-approve | 自动批准 | 始终批准 | 权限相关术语 | | Checkpoint | 存档点 | 检查点/快照 | 技术概念统一 | | MCP Server | MCP 服务 | MCP 服务器 | 技术组件 | -| Human Relay | 人工辅助模式 | 人工中继 | 功能描述清晰 | | Network Timeout | 请求超时 | 网络超时 | 更准确描述 | | Terminal | 终端 | 命令行 | 技术术语统一 | | diff | 差异更新 | 差分/补丁 | 代码变更 | diff --git a/.roo/skills/evals-context/SKILL.md b/.roo/skills/evals-context/SKILL.md new file mode 100644 index 0000000000..985b788b94 --- /dev/null +++ b/.roo/skills/evals-context/SKILL.md @@ -0,0 +1,188 @@ +--- +name: evals-context +description: Provides context about the Roo Code evals system structure in this monorepo. Use when tasks mention "evals", "evaluation", "eval runs", "eval exercises", or working with the evals infrastructure. Helps distinguish between the evals execution system (packages/evals, apps/web-evals) and the public website evals display page (apps/web-roo-code/src/app/evals). +--- + +# Evals Codebase Context + +## When to Use This Skill + +Use this skill when the task involves: + +- Modifying or debugging the evals execution infrastructure +- Adding new eval exercises or languages +- Working with the evals web interface (apps/web-evals) +- Modifying the public evals display page on roocode.com +- Understanding where evals code lives in this monorepo + +## When NOT to Use This Skill + +Do NOT use this skill when: + +- Working on unrelated parts of the codebase (extension, webview-ui, etc.) +- The task is purely about the VS Code extension's core functionality +- Working on the main website pages that don't involve evals + +## Key Disambiguation: Two "Evals" Locations + +This monorepo has **two distinct evals-related locations** that can cause confusion: + +| Component | Path | Purpose | +| --------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- | +| **Evals Execution System** | `packages/evals/` | Core eval infrastructure: CLI, DB schema, Docker configs | +| **Evals Management UI** | `apps/web-evals/` | Next.js app for creating/monitoring eval runs (localhost:3446) | +| **Website Evals Page** | `apps/web-roo-code/src/app/evals/` | Public roocode.com page displaying eval results | +| **External Exercises Repo** | [Roo-Code-Evals](https://github.com/RooCodeInc/Roo-Code-Evals) | Actual coding exercises (NOT in this monorepo) | + +## Directory Structure Reference + +### `packages/evals/` - Core Evals Package + +``` +packages/evals/ +├── ARCHITECTURE.md # Detailed architecture documentation +├── ADDING-EVALS.md # Guide for adding new exercises/languages +├── README.md # Setup and running instructions +├── docker-compose.yml # Container orchestration +├── Dockerfile.runner # Runner container definition +├── Dockerfile.web # Web app container +├── drizzle.config.ts # Database ORM config +├── src/ +│ ├── index.ts # Package exports +│ ├── cli/ # CLI commands for running evals +│ │ ├── runEvals.ts # Orchestrates complete eval runs +│ │ ├── runTask.ts # Executes individual tasks in containers +│ │ ├── runUnitTest.ts # Validates task completion via tests +│ │ └── redis.ts # Redis pub/sub integration +│ ├── db/ +│ │ ├── schema.ts # Database schema (runs, tasks) +│ │ ├── queries/ # Database query functions +│ │ └── migrations/ # SQL migrations +│ └── exercises/ +│ └── index.ts # Exercise loading utilities +└── scripts/ + └── setup.sh # Local macOS setup script +``` + +### `apps/web-evals/` - Evals Management Web App + +``` +apps/web-evals/ +├── src/ +│ ├── app/ +│ │ ├── page.tsx # Home page (runs list) +│ │ ├── runs/ +│ │ │ ├── new/ # Create new eval run +│ │ │ └── [id]/ # View specific run status +│ │ └── api/runs/ # SSE streaming endpoint +│ ├── actions/ # Server actions +│ │ ├── runs.ts # Run CRUD operations +│ │ ├── tasks.ts # Task queries +│ │ ├── exercises.ts # Exercise listing +│ │ └── heartbeat.ts # Controller health checks +│ ├── hooks/ # React hooks (SSE, models, etc.) +│ └── lib/ # Utilities and schemas +``` + +### `apps/web-roo-code/src/app/evals/` - Public Website Evals Page + +``` +apps/web-roo-code/src/app/evals/ +├── page.tsx # Fetches and displays public eval results +├── evals.tsx # Main evals display component +├── plot.tsx # Visualization component +└── types.ts # EvalRun type (extends packages/evals types) +``` + +This page **displays** eval results on the public roocode.com website. It imports types from `@roo-code/evals` but does NOT run evals. + +## Architecture Overview + +The evals system is a distributed evaluation platform that runs AI coding tasks in isolated VS Code environments: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Web App (apps/web-evals) ──────────────────────────────── │ +│ │ │ +│ ▼ │ +│ PostgreSQL ◄────► Controller Container │ +│ │ │ │ +│ ▼ ▼ │ +│ Redis ◄───► Runner Containers (1-25 parallel) │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key components:** + +- **Controller**: Orchestrates eval runs, spawns runners, manages task queue (p-queue) +- **Runner**: Isolated Docker container with VS Code + Roo Code extension + language runtimes +- **Redis**: Pub/sub for real-time events (NOT task queuing) +- **PostgreSQL**: Stores runs, tasks, metrics + +## Common Tasks Quick Reference + +### Adding a New Eval Exercise + +1. Add exercise to [Roo-Code-Evals](https://github.com/RooCodeInc/Roo-Code-Evals) repo (external) +2. See [`packages/evals/ADDING-EVALS.md`](packages/evals/ADDING-EVALS.md) for structure + +### Modifying Eval CLI Behavior + +Edit files in [`packages/evals/src/cli/`](packages/evals/src/cli/): + +- [`runEvals.ts`](packages/evals/src/cli/runEvals.ts) - Run orchestration +- [`runTask.ts`](packages/evals/src/cli/runTask.ts) - Task execution +- [`runUnitTest.ts`](packages/evals/src/cli/runUnitTest.ts) - Test validation + +### Modifying the Evals Web Interface + +Edit files in [`apps/web-evals/src/`](apps/web-evals/src/): + +- [`app/runs/new/new-run.tsx`](apps/web-evals/src/app/runs/new/new-run.tsx) - New run form +- [`actions/runs.ts`](apps/web-evals/src/actions/runs.ts) - Run server actions + +### Modifying the Public Evals Display Page + +Edit files in [`apps/web-roo-code/src/app/evals/`](apps/web-roo-code/src/app/evals/): + +- [`evals.tsx`](apps/web-roo-code/src/app/evals/evals.tsx) - Display component +- [`plot.tsx`](apps/web-roo-code/src/app/evals/plot.tsx) - Charts + +### Database Schema Changes + +1. Edit [`packages/evals/src/db/schema.ts`](packages/evals/src/db/schema.ts) +2. Generate migration: `cd packages/evals && pnpm drizzle-kit generate` +3. Apply migration: `pnpm drizzle-kit migrate` + +## Running Evals Locally + +```bash +# From repo root +pnpm evals + +# Opens web UI at http://localhost:3446 +``` + +**Ports (defaults):** + +- PostgreSQL: 5433 +- Redis: 6380 +- Web: 3446 + +## Testing + +```bash +# packages/evals tests +cd packages/evals && npx vitest run + +# apps/web-evals tests +cd apps/web-evals && npx vitest run +``` + +## Key Types/Exports from `@roo-code/evals` + +The package exports are defined in [`packages/evals/src/index.ts`](packages/evals/src/index.ts): + +- Database queries: `getRuns`, `getTasks`, `getTaskMetrics`, etc. +- Schema types: `Run`, `Task`, `TaskMetrics` +- Used by both `apps/web-evals` and `apps/web-roo-code` diff --git a/CHANGELOG.md b/CHANGELOG.md index 10d7557569..5abc65c291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,88 @@ # Roo Code Changelog +## [3.39.0] - 2026-01-08 + +![3.39.0 Release - Kangaroo go BRRR](/releases/3.39.0-release.png) + +- Implement sticky provider profile for task-level API config persistence (#8010 by @hannesrudolph, PR #10018 by @hannesrudolph) +- Add support for image file @mentions (PR #10189 by @hannesrudolph) +- Rename YOLO to BRRR (#8574 by @mojomast, PR #10507 by @roomote) +- Add debug-mode proxy routing for debugging API calls (#7042 by @SleeperSmith, PR #10467 by @hannesrudolph) +- Add Kimi K2 thinking model to Fireworks AI provider (#9201 by @kavehsfv, PR #9202 by @roomote) +- Add xhigh reasoning effort to OpenAI compatible endpoints (#10060 by @Soorma718, PR #10061 by @roomote) +- Filter @ mention file search results using .rooignore (#10169 by @jerrill-johnson-bitwerx, PR #10174 by @roomote) +- Add image support documentation to read_file native tool description (#10440 by @nabilfreeman, PR #10442 by @roomote) +- Add zai-glm-4.7 to Cerebras models (PR #10500 by @sebastiand-cerebras) +- VSCode shim and basic CLI for running Roo Code headlessly (PR #10452 by @cte) +- Add CLI installer for headless Roo Code (PR #10474 by @cte) +- Add option to use CLI for evals (PR #10456 by @cte) +- Remember last Roo model selection in web-evals and add evals skill (PR #10470 by @hannesrudolph) +- Tweak the style of follow up suggestion modes (PR #9260 by @mrubens) +- Fix: Handle PowerShell ENOENT error in os-name on Windows (#9859 by @Yang-strive, PR #9897 by @roomote) +- Fix: Make command chaining examples shell-aware for Windows compatibility (#10352 by @AlexNek, PR #10434 by @roomote) +- Fix: Preserve tool_use blocks for all tool_results in kept messages during condensation (PR #10471 by @daniel-lxs) +- Fix: Add additionalProperties: false to MCP tool schemas for OpenAI Responses API (PR #10472 by @daniel-lxs) +- Fix: Prevent duplicate tool_result blocks causing API errors (PR #10497 by @daniel-lxs) +- Fix: Add explicit deduplication for duplicate tool_result blocks (#10465 by @nabilfreeman, PR #10466 by @roomote) +- Fix: Use task stored API config as fallback for rate limit (PR #10266 by @roomote) +- Fix: Remove legacy Claude 2 series models from Bedrock provider (#9220 by @KevinZhao, PR #10501 by @roomote) +- Fix: Add missing description fields for debugProxy configuration (PR #10505 by @roomote) +- Fix: Glitchy kangaroo bounce animation on welcome screen (PR #10035 by @objectiveSee) + +## [3.38.3] - 2026-01-03 + +- Feat: Add option in Context settings to recursively load `.roo/rules` and `AGENTS.md` from subdirectories (PR #10446 by @mrubens) +- Fix: Stop frequent Claude Code sign-ins by hardening OAuth refresh token handling (PR #10410 by @hannesrudolph) +- Fix: Add `maxConcurrentFileReads` limit to native `read_file` tool schema (PR #10449 by @app/roomote) +- Fix: Add type check for `lastMessage.text` in TTS useEffect to prevent runtime errors (PR #10431 by @app/roomote) + +## [3.38.2] - 2025-12-31 + +![3.38.2 Release - Skill Alignment](/releases/3.38.2-release.png) + +- Align skills system with Agent Skills specification (PR #10409 by @hannesrudolph) +- Prevent write_to_file from creating files at truncated paths (PR #10415 by @mrubens and @daniel-lxs) +- Update Cerebras maxTokens to 16384 (PR #10387 by @sebastiand-cerebras) +- Fix rate limit wait display (PR #10389 by @hannesrudolph) +- Remove human-relay provider (PR #10388 by @hannesrudolph) +- Replace Todo Lists video with Context Management video in documentation (PR #10375 by @SannidhyaSah) + +## [3.38.1] - 2025-12-29 + +![3.38.1 Release - Bug Fixes and Stability](/releases/3.38.1-release.png) + +- Fix: Flush pending tool results before condensing context (PR #10379 by @daniel-lxs) +- Fix: Revert mergeToolResultText for OpenAI-compatible providers (PR #10381 by @hannesrudolph) +- Fix: Enforce maxConcurrentFileReads limit in read_file tool (PR #10363 by @roomote) +- Fix: Improve feedback message when read_file is used on a directory (PR #10371 by @roomote) +- Fix: Handle custom tool use similarly to MCP tools for IPC schema purposes (PR #10364 by @jr) +- Fix: Correct GitHub repository URL in marketing page (#10376 by @jishnuteegala, PR #10377 by @roomote) +- Docs: Clarify path to Security Settings in privacy policy (PR #10367 by @roomote) + +## [3.38.0] - 2025-12-27 + +![3.38.0 Release - Skills](/releases/3.38.0-release.png) + +- Add support for [Agent Skills](https://agentskills.io/), enabling reusable packages of prompts, tools, and resources to extend Roo's capabilities (PR #10335 by @mrubens) +- Add optional mode field to slash command front matter, allowing commands to automatically switch to a specific mode when triggered (PR #10344 by @app/roomote) +- Add support for npm packages and .env files to custom tools, allowing custom tools to import dependencies and access environment variables (PR #10336 by @cte) +- Remove simpleReadFileTool feature, streamlining the file reading experience (PR #10254 by @app/roomote) +- Remove OpenRouter Transforms feature (PR #10341 by @app/roomote) +- Fix mergeToolResultText handling in Roo provider (PR #10359 by @mrubens) + +## [3.37.1] - 2025-12-23 + +![3.37.1 Release - Tool Fixes and Provider Improvements](/releases/3.37.1-release.png) + +- Fix: Send native tool definitions by default for OpenAI to ensure proper tool usage (PR #10314 by @hannesrudolph) +- Fix: Preserve reasoning_details shape to prevent malformed responses when processing model output (PR #10313 by @hannesrudolph) +- Fix: Drain queued messages while waiting for ask to prevent message loss (PR #10315 by @hannesrudolph) +- Feat: Add grace retry for empty assistant messages to improve reliability (PR #10297 by @hannesrudolph) +- Feat: Enable mergeToolResultText for all OpenAI-compatible providers for better tool result handling (PR #10299 by @hannesrudolph) +- Feat: Enable mergeToolResultText for Roo Code Cloud provider (PR #10301 by @hannesrudolph) +- Feat: Strengthen native tool-use guidance in prompts for improved model behavior (PR #10311 by @hannesrudolph) +- UX: Account-centric signup flow for improved onboarding experience (PR #10306 by @brunobergher) + ## [3.37.0] - 2025-12-22 ![3.37.0 Release - Custom Tool Calling](/releases/3.37.0-release.png) diff --git a/README.md b/README.md index 9e98cd8845..5f6469237a 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ - [简体中文](locales/zh-CN/README.md) - [繁體中文](locales/zh-TW/README.md) - ... - + --- @@ -66,10 +66,10 @@ Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) •
-| | | | -| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | -|
Custom Modes |
Checkpoints |
Todo Lists | +| | | | +| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | +|
Custom Modes |
Checkpoints |
Context Management |

diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000000..3e78192d4e --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,231 @@ +# @roo-code/cli + +Command Line Interface for Roo Code - Run the Roo Code agent from the terminal without VSCode. + +## Overview + +This CLI uses the `@roo-code/vscode-shim` package to provide a VSCode API compatibility layer, allowing the main Roo Code extension to run in a Node.js environment. + +## Installation + +### Quick Install (Recommended) + +Install the Roo Code CLI with a single command: + +```bash +curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +``` + +**Requirements:** + +- Node.js 20 or higher +- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64) + +**Custom installation directory:** + +```bash +ROO_INSTALL_DIR=/opt/roo-code ROO_BIN_DIR=/usr/local/bin curl -fsSL ... | sh +``` + +**Install a specific version:** + +```bash +ROO_VERSION=0.1.0 curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +``` + +### Updating + +Re-run the install script to update to the latest version: + +```bash +curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +``` + +### Uninstalling + +```bash +rm -rf ~/.roo/cli ~/.local/bin/roo +``` + +### Development Installation + +For contributing or development: + +```bash +# From the monorepo root. +pnpm install + +# Build the main extension first. +pnpm --filter roo-cline bundle + +# Build the cli. +pnpm --filter @roo-code/cli build +``` + +## Usage + +### Interactive Mode (Default) + +By default, the CLI prompts for approval before executing actions: + +```bash +export OPENROUTER_API_KEY=sk-or-v1-... + +roo "What is this project?" --workspace ~/Documents/my-project +``` + +In interactive mode: + +- Tool executions prompt for yes/no approval +- Commands prompt for yes/no approval +- Followup questions show suggestions and wait for user input +- Browser and MCP actions prompt for approval + +### Non-Interactive Mode (`-y`) + +For automation and scripts, use `-y` to auto-approve all actions: + +```bash +roo -y "Refactor the utils.ts file" --workspace ~/Documents/my-project +``` + +In non-interactive mode: + +- Tool, command, browser, and MCP actions are auto-approved +- Followup questions show a 10-second timeout, then auto-select the first suggestion +- Typing any key cancels the timeout and allows manual input + +## Options + +| Option | Description | Default | +| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- | +| `-w, --workspace ` | Workspace path to operate in | Current directory | +| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | +| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` | +| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | +| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` | +| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` | +| `-k, --api-key ` | API key for the LLM provider | From env var | +| `-p, --provider ` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` | +| `-m, --model ` | Model to use | Provider default | +| `-M, --mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | +| `-r, --reasoning-effort ` | Reasoning effort level (none, minimal, low, medium, high, xhigh) | `medium` | + +By default, the CLI runs in quiet mode (suppressing VSCode/extension logs) and only shows assistant output. Use `-v` to see all logs, or `-d` for detailed debug information. + +## Environment Variables + +The CLI will look for API keys in environment variables if not provided via `--api-key`: + +| Provider | Environment Variable | +| ------------- | -------------------- | +| anthropic | `ANTHROPIC_API_KEY` | +| openai | `OPENAI_API_KEY` | +| openrouter | `OPENROUTER_API_KEY` | +| google/gemini | `GOOGLE_API_KEY` | +| mistral | `MISTRAL_API_KEY` | +| deepseek | `DEEPSEEK_API_KEY` | +| bedrock | `AWS_ACCESS_KEY_ID` | + +## Architecture + +``` +┌─────────────────┐ +│ CLI Entry │ +│ (index.ts) │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ ExtensionHost │ +│ (extension- │ +│ host.ts) │ +└────────┬────────┘ + │ + ┌────┴────┐ + │ │ + ▼ ▼ +┌───────┐ ┌──────────┐ +│vscode │ │Extension │ +│-shim │ │ Bundle │ +└───────┘ └──────────┘ +``` + +## How It Works + +1. **CLI Entry Point** (`index.ts`): Parses command line arguments and initializes the ExtensionHost + +2. **ExtensionHost** (`extension-host.ts`): + + - Creates a VSCode API mock using `@roo-code/vscode-shim` + - Intercepts `require('vscode')` to return the mock + - Loads and activates the extension bundle + - Manages bidirectional message flow + +3. **Message Flow**: + - CLI → Extension: `emit("webviewMessage", {...})` + - Extension → CLI: `emit("extensionWebviewMessage", {...})` + +## Current Limitations + +- **No TUI**: Output is plain text (no React/Ink UI yet) +- **No configuration file**: Settings are passed via command line flags +- **No persistence**: Each run is a fresh session + +## Development + +```bash +# Watch mode for development +pnpm dev + +# Run tests +pnpm test + +# Type checking +pnpm check-types + +# Linting +pnpm lint +``` + +## Releasing + +To create a new release, run the release script from the monorepo root: + +```bash +# Release using version from package.json +./apps/cli/scripts/release.sh + +# Release with a specific version +./apps/cli/scripts/release.sh 0.1.0 +``` + +The script will: + +1. Build the extension and CLI +2. Create a platform-specific tarball (for your current OS/architecture) +3. Create a GitHub release with the tarball attached + +**Prerequisites:** + +- GitHub CLI (`gh`) installed and authenticated (`gh auth login`) +- pnpm installed + +## Troubleshooting + +### Extension bundle not found + +Make sure you've built the main extension first: + +```bash +cd src +pnpm bundle +``` + +### Module resolution errors + +The CLI expects the extension to be a CommonJS bundle. Make sure the extension's esbuild config outputs CommonJS. + +### "vscode" module not found + +The CLI intercepts `require('vscode')` calls. If you see this error, the module resolution interception may have failed. diff --git a/apps/cli/eslint.config.mjs b/apps/cli/eslint.config.mjs new file mode 100644 index 0000000000..694bf73664 --- /dev/null +++ b/apps/cli/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@roo-code/config-eslint/base" + +/** @type {import("eslint").Linter.Config} */ +export default [...config] diff --git a/apps/cli/install.sh b/apps/cli/install.sh new file mode 100755 index 0000000000..ca82ecfdd8 --- /dev/null +++ b/apps/cli/install.sh @@ -0,0 +1,287 @@ +#!/bin/sh +# Roo Code CLI Installer +# Usage: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +# +# Environment variables: +# ROO_INSTALL_DIR - Installation directory (default: ~/.roo/cli) +# ROO_BIN_DIR - Binary symlink directory (default: ~/.local/bin) +# ROO_VERSION - Specific version to install (default: latest) + +set -e + +# Configuration +INSTALL_DIR="${ROO_INSTALL_DIR:-$HOME/.roo/cli}" +BIN_DIR="${ROO_BIN_DIR:-$HOME/.local/bin}" +REPO="RooCodeInc/Roo-Code" +MIN_NODE_VERSION=20 + +# Color output (only if terminal supports it) +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + BOLD='\033[1m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + BOLD='' + NC='' +fi + +info() { printf "${GREEN}==>${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; } +error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; } + +# Check Node.js version +check_node() { + if ! command -v node >/dev/null 2>&1; then + error "Node.js is not installed. Please install Node.js $MIN_NODE_VERSION or higher. + +Install Node.js: + - macOS: brew install node + - Linux: https://nodejs.org/en/download/package-manager + - Or use a version manager like fnm, nvm, or mise" + fi + + NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1) + if [ "$NODE_VERSION" -lt "$MIN_NODE_VERSION" ]; then + error "Node.js $MIN_NODE_VERSION+ required. Found: $(node -v) + +Please upgrade Node.js to version $MIN_NODE_VERSION or higher." + fi + + info "Found Node.js $(node -v)" +} + +# Detect OS and architecture +detect_platform() { + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$OS" in + darwin) OS="darwin" ;; + linux) OS="linux" ;; + mingw*|msys*|cygwin*) + error "Windows is not supported by this installer. Please use WSL or install manually." + ;; + *) error "Unsupported OS: $OS" ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH="x64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) error "Unsupported architecture: $ARCH" ;; + esac + + PLATFORM="${OS}-${ARCH}" + info "Detected platform: $PLATFORM" +} + +# Get latest release version or use specified version +get_version() { + if [ -n "$ROO_VERSION" ]; then + VERSION="$ROO_VERSION" + info "Using specified version: $VERSION" + return + fi + + info "Fetching latest version..." + + # Try to get the latest cli release + RELEASES_JSON=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" 2>/dev/null) || { + error "Failed to fetch releases from GitHub. Check your internet connection." + } + + # Extract the latest cli-v* tag + VERSION=$(echo "$RELEASES_JSON" | + grep -o '"tag_name": "cli-v[^"]*"' | + head -1 | + sed 's/"tag_name": "cli-v//' | + sed 's/"//') + + if [ -z "$VERSION" ]; then + error "Could not find any CLI releases. The CLI may not have been released yet." + fi + + info "Latest version: $VERSION" +} + +# Download and extract +download_and_install() { + TARBALL="roo-cli-${PLATFORM}.tar.gz" + URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}" + + info "Downloading from $URL..." + + # Create temp directory + TMP_DIR=$(mktemp -d) + trap "rm -rf $TMP_DIR" EXIT + + # Download with progress indicator + HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || { + if [ "$HTTP_CODE" = "404" ]; then + error "Release not found for platform $PLATFORM version $VERSION. + +Available at: https://github.com/$REPO/releases" + fi + error "Download failed. HTTP code: $HTTP_CODE" + } + + # Verify we got something + if [ ! -s "$TMP_DIR/$TARBALL" ]; then + error "Downloaded file is empty. Please try again." + fi + + # Remove old installation if exists + if [ -d "$INSTALL_DIR" ]; then + info "Removing previous installation..." + rm -rf "$INSTALL_DIR" + fi + + mkdir -p "$INSTALL_DIR" + + # Extract + info "Extracting to $INSTALL_DIR..." + tar -xzf "$TMP_DIR/$TARBALL" -C "$INSTALL_DIR" --strip-components=1 || { + error "Failed to extract tarball. The download may be corrupted." + } + + # Save ripgrep binary before npm install (npm install will overwrite node_modules) + RIPGREP_BIN="" + if [ -f "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" ]; then + RIPGREP_BIN="$TMP_DIR/rg" + cp "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" "$RIPGREP_BIN" + fi + + # Install npm dependencies + info "Installing dependencies..." + cd "$INSTALL_DIR" + npm install --production --silent 2>/dev/null || { + warn "npm install failed, trying with --legacy-peer-deps..." + npm install --production --legacy-peer-deps --silent 2>/dev/null || { + error "Failed to install dependencies. Make sure npm is available." + } + } + cd - > /dev/null + + # Restore ripgrep binary after npm install + if [ -n "$RIPGREP_BIN" ] && [ -f "$RIPGREP_BIN" ]; then + mkdir -p "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin" + cp "$RIPGREP_BIN" "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" + chmod +x "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" + fi + + # Make executable + chmod +x "$INSTALL_DIR/bin/roo" + + # Also make ripgrep executable if it exists + if [ -f "$INSTALL_DIR/bin/rg" ]; then + chmod +x "$INSTALL_DIR/bin/rg" + fi +} + +# Create symlink in bin directory +setup_bin() { + mkdir -p "$BIN_DIR" + + # Remove old symlink if exists + if [ -L "$BIN_DIR/roo" ] || [ -f "$BIN_DIR/roo" ]; then + rm -f "$BIN_DIR/roo" + fi + + ln -sf "$INSTALL_DIR/bin/roo" "$BIN_DIR/roo" + info "Created symlink: $BIN_DIR/roo" +} + +# Check if bin dir is in PATH and provide instructions +check_path() { + case ":$PATH:" in + *":$BIN_DIR:"*) + # Already in PATH + return 0 + ;; + esac + + warn "$BIN_DIR is not in your PATH" + echo "" + echo "Add this line to your shell profile:" + echo "" + + # Detect shell and provide specific instructions + SHELL_NAME=$(basename "$SHELL") + case "$SHELL_NAME" in + zsh) + echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.zshrc" + echo " source ~/.zshrc" + ;; + bash) + if [ -f "$HOME/.bashrc" ]; then + echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bashrc" + echo " source ~/.bashrc" + else + echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bash_profile" + echo " source ~/.bash_profile" + fi + ;; + fish) + echo " set -Ux fish_user_paths $BIN_DIR \$fish_user_paths" + ;; + *) + echo " export PATH=\"$BIN_DIR:\$PATH\"" + ;; + esac + echo "" +} + +# Verify installation +verify_install() { + if [ -x "$BIN_DIR/roo" ]; then + info "Verifying installation..." + # Just check if it runs without error + "$BIN_DIR/roo" --version >/dev/null 2>&1 || true + fi +} + +# Print success message +print_success() { + echo "" + printf "${GREEN}${BOLD}✓ Roo Code CLI installed successfully!${NC}\n" + echo "" + echo " Installation: $INSTALL_DIR" + echo " Binary: $BIN_DIR/roo" + echo " Version: $VERSION" + echo "" + echo " ${BOLD}Get started:${NC}" + echo " roo --help" + echo "" + echo " ${BOLD}Example:${NC}" + echo " export OPENROUTER_API_KEY=sk-or-v1-..." + echo " roo \"What is this project?\" --workspace ~/my-project" + echo "" +} + +# Main +main() { + echo "" + printf "${BLUE}${BOLD}" + echo " ╭─────────────────────────────────╮" + echo " │ Roo Code CLI Installer │" + echo " ╰─────────────────────────────────╯" + printf "${NC}" + echo "" + + check_node + detect_platform + get_version + download_and_install + setup_bin + check_path + verify_install + print_success +} + +main "$@" diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000000..f4c4a3bcb5 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,35 @@ +{ + "name": "@roo-code/cli", + "version": "0.1.0", + "description": "Roo Code CLI - Run the Roo Code agent from the command line", + "private": true, + "type": "module", + "main": "dist/index.js", + "bin": { + "roo": "dist/index.js" + }, + "scripts": { + "format": "prettier --write 'src/**/*.ts'", + "lint": "eslint src --ext .ts --max-warnings=0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "build": "tsup", + "start": "node dist/index.js", + "clean": "rimraf dist .turbo" + }, + "dependencies": { + "@roo-code/types": "workspace:^", + "@roo-code/vscode-shim": "workspace:^", + "@vscode/ripgrep": "^1.15.9", + "commander": "^12.1.0" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "^24.1.0", + "rimraf": "^6.0.1", + "tsup": "^8.4.0", + "typescript": "5.8.3", + "vitest": "^3.2.3" + } +} diff --git a/apps/cli/scripts/release.sh b/apps/cli/scripts/release.sh new file mode 100755 index 0000000000..43d5298956 --- /dev/null +++ b/apps/cli/scripts/release.sh @@ -0,0 +1,363 @@ +#!/bin/bash +# Roo Code CLI Release Script +# +# Usage: +# ./apps/cli/scripts/release.sh [version] +# +# Examples: +# ./apps/cli/scripts/release.sh # Use version from package.json +# ./apps/cli/scripts/release.sh 0.1.0 # Specify version +# +# This script: +# 1. Builds the extension and CLI +# 2. Creates a tarball for the current platform +# 3. Creates a GitHub release and uploads the tarball +# +# Prerequisites: +# - GitHub CLI (gh) installed and authenticated +# - pnpm installed +# - Run from the monorepo root directory + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +info() { printf "${GREEN}==>${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; } +error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; } +step() { printf "${BLUE}${BOLD}[%s]${NC} %s\n" "$1" "$2"; } + +# Get script directory and repo root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +CLI_DIR="$REPO_ROOT/apps/cli" + +# Detect current platform +detect_platform() { + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$OS" in + darwin) OS="darwin" ;; + linux) OS="linux" ;; + *) error "Unsupported OS: $OS" ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH="x64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) error "Unsupported architecture: $ARCH" ;; + esac + + PLATFORM="${OS}-${ARCH}" +} + +# Check prerequisites +check_prerequisites() { + step "1/7" "Checking prerequisites..." + + if ! command -v gh &> /dev/null; then + error "GitHub CLI (gh) is not installed. Install it with: brew install gh" + fi + + if ! gh auth status &> /dev/null; then + error "GitHub CLI is not authenticated. Run: gh auth login" + fi + + if ! command -v pnpm &> /dev/null; then + error "pnpm is not installed." + fi + + if ! command -v node &> /dev/null; then + error "Node.js is not installed." + fi + + info "Prerequisites OK" +} + +# Get version +get_version() { + if [ -n "$1" ]; then + VERSION="$1" + else + VERSION=$(node -p "require('$CLI_DIR/package.json').version") + fi + + # Validate semver format + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then + error "Invalid version format: $VERSION (expected semver like 0.1.0)" + fi + + TAG="cli-v$VERSION" + info "Version: $VERSION (tag: $TAG)" +} + +# Build everything +build() { + step "2/7" "Building extension bundle..." + cd "$REPO_ROOT" + pnpm bundle + + step "3/7" "Building CLI..." + pnpm --filter @roo-code/cli build + + info "Build complete" +} + +# Create release tarball +create_tarball() { + step "4/7" "Creating release tarball for $PLATFORM..." + + RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}" + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Clean up any previous build + rm -rf "$RELEASE_DIR" + rm -f "$REPO_ROOT/$TARBALL" + + # Create directory structure + mkdir -p "$RELEASE_DIR/bin" + mkdir -p "$RELEASE_DIR/lib" + mkdir -p "$RELEASE_DIR/extension" + + # Copy CLI dist files + info "Copying CLI files..." + cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/" + + # Create package.json for npm install (only runtime dependencies) + info "Creating package.json..." + node -e " + const pkg = require('$CLI_DIR/package.json'); + const newPkg = { + name: '@roo-code/cli', + version: pkg.version, + type: 'module', + dependencies: { + commander: pkg.dependencies.commander + } + }; + console.log(JSON.stringify(newPkg, null, 2)); + " > "$RELEASE_DIR/package.json" + + # Copy extension bundle + info "Copying extension bundle..." + cp -r "$REPO_ROOT/src/dist/"* "$RELEASE_DIR/extension/" + + # Add package.json to extension directory to mark it as CommonJS + # This is necessary because the main package.json has "type": "module" + # but the extension bundle is CommonJS + echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" + + # Find and copy ripgrep binary + # The extension looks for ripgrep at: appRoot/node_modules/@vscode/ripgrep/bin/rg + # The CLI sets appRoot to the CLI package root, so we need to put ripgrep there + info "Looking for ripgrep binary..." + RIPGREP_PATH=$(find "$REPO_ROOT/node_modules" -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) + if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then + info "Found ripgrep at: $RIPGREP_PATH" + # Create the expected directory structure for the extension to find ripgrep + mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/" + chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg" + # Also keep a copy in bin/ for direct access + mkdir -p "$RELEASE_DIR/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" + chmod +x "$RELEASE_DIR/bin/rg" + else + warn "ripgrep binary not found - users will need ripgrep installed" + fi + + # Create the wrapper script + info "Creating wrapper script..." + cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF' +#!/usr/bin/env node + +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Set environment variables for the CLI +process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); +process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg'); + +// Import and run the actual CLI +await import(join(__dirname, '..', 'lib', 'index.js')); +WRAPPER_EOF + + chmod +x "$RELEASE_DIR/bin/roo" + + # Create version file + echo "$VERSION" > "$RELEASE_DIR/VERSION" + + # Create tarball + info "Creating tarball..." + cd "$REPO_ROOT" + tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")" + + # Clean up release directory + rm -rf "$RELEASE_DIR" + + # Show size + TARBALL_PATH="$REPO_ROOT/$TARBALL" + TARBALL_SIZE=$(ls -lh "$TARBALL_PATH" | awk '{print $5}') + info "Created: $TARBALL ($TARBALL_SIZE)" +} + +# Create checksum +create_checksum() { + step "5/7" "Creating checksum..." + cd "$REPO_ROOT" + + if command -v sha256sum &> /dev/null; then + sha256sum "$TARBALL" > "${TARBALL}.sha256" + elif command -v shasum &> /dev/null; then + shasum -a 256 "$TARBALL" > "${TARBALL}.sha256" + else + warn "No sha256sum or shasum found, skipping checksum" + return + fi + + info "Checksum: $(cat "${TARBALL}.sha256")" +} + +# Check if release already exists +check_existing_release() { + step "6/7" "Checking for existing release..." + + if gh release view "$TAG" &> /dev/null; then + warn "Release $TAG already exists" + read -p "Do you want to delete it and create a new one? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + info "Deleting existing release..." + gh release delete "$TAG" --yes + # Also delete the tag if it exists + git tag -d "$TAG" 2>/dev/null || true + git push origin ":refs/tags/$TAG" 2>/dev/null || true + else + error "Aborted. Use a different version or delete the existing release manually." + fi + fi +} + +# Create GitHub release +create_release() { + step "7/7" "Creating GitHub release..." + cd "$REPO_ROOT" + + RELEASE_NOTES=$(cat << EOF +## Installation + +\`\`\`bash +curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +\`\`\` + +Or install a specific version: +\`\`\`bash +ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +\`\`\` + +## Requirements + +- Node.js 20 or higher +- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64) + +## Usage + +\`\`\`bash +# Set your API key +export OPENROUTER_API_KEY=sk-or-v1-... + +# Run a task +roo "What is this project?" --workspace ~/my-project + +# See all options +roo --help +\`\`\` + +## Platform Support + +This release includes: +- \`roo-cli-${PLATFORM}.tar.gz\` - Built on $(uname -s) $(uname -m) + +> **Note:** Additional platforms will be added as needed. If you need a different platform, please open an issue. + +## Checksum + +\`\`\` +$(cat "${TARBALL}.sha256" 2>/dev/null || echo "N/A") +\`\`\` +EOF +) + + # Get the current commit SHA for the release target + COMMIT_SHA=$(git rev-parse HEAD) + info "Creating release at commit: ${COMMIT_SHA:0:8}" + + # Create release (gh will create the tag automatically) + info "Creating release..." + RELEASE_FILES="$TARBALL" + if [ -f "${TARBALL}.sha256" ]; then + RELEASE_FILES="$RELEASE_FILES ${TARBALL}.sha256" + fi + + gh release create "$TAG" \ + --title "Roo Code CLI v$VERSION" \ + --notes "$RELEASE_NOTES" \ + --prerelease \ + --target "$COMMIT_SHA" \ + $RELEASE_FILES + + info "Release created!" +} + +# Cleanup +cleanup() { + info "Cleaning up..." + cd "$REPO_ROOT" + rm -f "$TARBALL" "${TARBALL}.sha256" +} + +# Print summary +print_summary() { + echo "" + printf "${GREEN}${BOLD}✓ Release v$VERSION created successfully!${NC}\n" + echo "" + echo " Release URL: https://github.com/RooCodeInc/Roo-Code/releases/tag/$TAG" + echo "" + echo " Install with:" + echo " curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" + echo "" +} + +# Main +main() { + echo "" + printf "${BLUE}${BOLD}" + echo " ╭─────────────────────────────────╮" + echo " │ Roo Code CLI Release Script │" + echo " ╰─────────────────────────────────╯" + printf "${NC}" + echo "" + + detect_platform + check_prerequisites + get_version "$1" + build + create_tarball + create_checksum + check_existing_release + create_release + cleanup + print_summary +} + +main "$@" diff --git a/apps/cli/src/__tests__/extension-host.test.ts b/apps/cli/src/__tests__/extension-host.test.ts new file mode 100644 index 0000000000..509ad27d1e --- /dev/null +++ b/apps/cli/src/__tests__/extension-host.test.ts @@ -0,0 +1,1164 @@ +// pnpm --filter @roo-code/cli test src/__tests__/extension-host.test.ts + +import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js" +import { EventEmitter } from "events" +import type { ProviderName } from "@roo-code/types" + +vi.mock("@roo-code/vscode-shim", () => ({ + createVSCodeAPI: vi.fn(() => ({ + context: { extensionPath: "/test/extension" }, + })), +})) + +/** + * Create a test ExtensionHost with default options + */ +function createTestHost({ + mode = "code", + apiProvider = "openrouter", + model = "test-model", + ...options +}: Partial = {}): ExtensionHost { + return new ExtensionHost({ + mode, + apiProvider, + model, + workspacePath: "/test/workspace", + extensionPath: "/test/extension", + ...options, + }) +} + +// Type for accessing private members +type PrivateHost = Record + +/** + * Helper to access private members for testing + */ +function getPrivate(host: ExtensionHost, key: string): T { + return (host as unknown as PrivateHost)[key] as T +} + +/** + * Helper to call private methods for testing + */ +function callPrivate(host: ExtensionHost, method: string, ...args: unknown[]): T { + const fn = (host as unknown as PrivateHost)[method] as ((...a: unknown[]) => T) | undefined + if (!fn) throw new Error(`Method ${method} not found`) + return fn.apply(host, args) +} + +/** + * Helper to spy on private methods + * This uses a more permissive type to avoid TypeScript errors with vi.spyOn on private methods + */ +function spyOnPrivate(host: ExtensionHost, method: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return vi.spyOn(host as any, method) +} + +describe("ExtensionHost", () => { + beforeEach(() => { + vi.resetAllMocks() + // Clean up globals + delete (global as Record).vscode + delete (global as Record).__extensionHost + }) + + describe("constructor", () => { + it("should store options correctly", () => { + const options: ExtensionHostOptions = { + mode: "code", + workspacePath: "/my/workspace", + extensionPath: "/my/extension", + verbose: true, + quiet: true, + apiKey: "test-key", + apiProvider: "openrouter", + model: "test-model", + } + + const host = new ExtensionHost(options) + + expect(getPrivate(host, "options")).toEqual(options) + }) + + it("should be an EventEmitter instance", () => { + const host = createTestHost() + expect(host).toBeInstanceOf(EventEmitter) + }) + + it("should initialize with default state values", () => { + const host = createTestHost() + + expect(getPrivate(host, "isWebviewReady")).toBe(false) + expect(getPrivate(host, "pendingMessages")).toEqual([]) + expect(getPrivate(host, "vscode")).toBeNull() + expect(getPrivate(host, "extensionModule")).toBeNull() + }) + }) + + describe("buildApiConfiguration", () => { + it.each([ + [ + "anthropic", + "test-key", + "test-model", + { apiProvider: "anthropic", apiKey: "test-key", apiModelId: "test-model" }, + ], + [ + "openrouter", + "or-key", + "or-model", + { + apiProvider: "openrouter", + openRouterApiKey: "or-key", + openRouterModelId: "or-model", + }, + ], + [ + "gemini", + "gem-key", + "gem-model", + { apiProvider: "gemini", geminiApiKey: "gem-key", apiModelId: "gem-model" }, + ], + [ + "openai-native", + "oai-key", + "oai-model", + { apiProvider: "openai-native", openAiNativeApiKey: "oai-key", apiModelId: "oai-model" }, + ], + [ + "openai", + "oai-key", + "oai-model", + { apiProvider: "openai", openAiApiKey: "oai-key", openAiModelId: "oai-model" }, + ], + [ + "mistral", + "mis-key", + "mis-model", + { apiProvider: "mistral", mistralApiKey: "mis-key", apiModelId: "mis-model" }, + ], + [ + "deepseek", + "ds-key", + "ds-model", + { apiProvider: "deepseek", deepSeekApiKey: "ds-key", apiModelId: "ds-model" }, + ], + ["xai", "xai-key", "xai-model", { apiProvider: "xai", xaiApiKey: "xai-key", apiModelId: "xai-model" }], + [ + "groq", + "groq-key", + "groq-model", + { apiProvider: "groq", groqApiKey: "groq-key", apiModelId: "groq-model" }, + ], + [ + "fireworks", + "fw-key", + "fw-model", + { apiProvider: "fireworks", fireworksApiKey: "fw-key", apiModelId: "fw-model" }, + ], + [ + "cerebras", + "cer-key", + "cer-model", + { apiProvider: "cerebras", cerebrasApiKey: "cer-key", apiModelId: "cer-model" }, + ], + [ + "sambanova", + "sn-key", + "sn-model", + { apiProvider: "sambanova", sambaNovaApiKey: "sn-key", apiModelId: "sn-model" }, + ], + [ + "ollama", + "oll-key", + "oll-model", + { apiProvider: "ollama", ollamaApiKey: "oll-key", ollamaModelId: "oll-model" }, + ], + ["lmstudio", undefined, "lm-model", { apiProvider: "lmstudio", lmStudioModelId: "lm-model" }], + [ + "litellm", + "lite-key", + "lite-model", + { apiProvider: "litellm", litellmApiKey: "lite-key", litellmModelId: "lite-model" }, + ], + [ + "huggingface", + "hf-key", + "hf-model", + { apiProvider: "huggingface", huggingFaceApiKey: "hf-key", huggingFaceModelId: "hf-model" }, + ], + ["chutes", "ch-key", "ch-model", { apiProvider: "chutes", chutesApiKey: "ch-key", apiModelId: "ch-model" }], + [ + "featherless", + "fl-key", + "fl-model", + { apiProvider: "featherless", featherlessApiKey: "fl-key", apiModelId: "fl-model" }, + ], + [ + "unbound", + "ub-key", + "ub-model", + { apiProvider: "unbound", unboundApiKey: "ub-key", unboundModelId: "ub-model" }, + ], + [ + "requesty", + "req-key", + "req-model", + { apiProvider: "requesty", requestyApiKey: "req-key", requestyModelId: "req-model" }, + ], + [ + "deepinfra", + "di-key", + "di-model", + { apiProvider: "deepinfra", deepInfraApiKey: "di-key", deepInfraModelId: "di-model" }, + ], + [ + "vercel-ai-gateway", + "vai-key", + "vai-model", + { + apiProvider: "vercel-ai-gateway", + vercelAiGatewayApiKey: "vai-key", + vercelAiGatewayModelId: "vai-model", + }, + ], + ["zai", "zai-key", "zai-model", { apiProvider: "zai", zaiApiKey: "zai-key", apiModelId: "zai-model" }], + [ + "baseten", + "bt-key", + "bt-model", + { apiProvider: "baseten", basetenApiKey: "bt-key", apiModelId: "bt-model" }, + ], + ["doubao", "db-key", "db-model", { apiProvider: "doubao", doubaoApiKey: "db-key", apiModelId: "db-model" }], + [ + "moonshot", + "ms-key", + "ms-model", + { apiProvider: "moonshot", moonshotApiKey: "ms-key", apiModelId: "ms-model" }, + ], + [ + "minimax", + "mm-key", + "mm-model", + { apiProvider: "minimax", minimaxApiKey: "mm-key", apiModelId: "mm-model" }, + ], + [ + "io-intelligence", + "io-key", + "io-model", + { apiProvider: "io-intelligence", ioIntelligenceApiKey: "io-key", ioIntelligenceModelId: "io-model" }, + ], + ])("should configure %s provider correctly", (provider, apiKey, model, expected) => { + const host = createTestHost({ + apiProvider: provider as ProviderName, + apiKey, + model, + }) + + const config = callPrivate>(host, "buildApiConfiguration") + + expect(config).toEqual(expected) + }) + + it("should use default provider when not specified", () => { + const host = createTestHost({ + apiKey: "test-key", + model: "test-model", + }) + + const config = callPrivate>(host, "buildApiConfiguration") + + expect(config.apiProvider).toBe("openrouter") + }) + + it("should handle missing apiKey gracefully", () => { + const host = createTestHost({ + apiProvider: "anthropic", + model: "test-model", + }) + + const config = callPrivate>(host, "buildApiConfiguration") + + expect(config.apiProvider).toBe("anthropic") + expect(config.apiKey).toBeUndefined() + expect(config.apiModelId).toBe("test-model") + }) + + it("should use default config for unknown providers", () => { + const host = createTestHost({ + apiProvider: "unknown-provider" as ProviderName, + apiKey: "test-key", + model: "test-model", + }) + + const config = callPrivate>(host, "buildApiConfiguration") + + expect(config.apiProvider).toBe("unknown-provider") + expect(config.apiKey).toBe("test-key") + expect(config.apiModelId).toBe("test-model") + }) + }) + + describe("webview provider registration", () => { + it("should register webview provider", () => { + const host = createTestHost() + const mockProvider = { resolveWebviewView: vi.fn() } + + host.registerWebviewProvider("test-view", mockProvider) + + const providers = getPrivate>(host, "webviewProviders") + expect(providers.get("test-view")).toBe(mockProvider) + }) + + it("should unregister webview provider", () => { + const host = createTestHost() + const mockProvider = { resolveWebviewView: vi.fn() } + + host.registerWebviewProvider("test-view", mockProvider) + host.unregisterWebviewProvider("test-view") + + const providers = getPrivate>(host, "webviewProviders") + expect(providers.has("test-view")).toBe(false) + }) + + it("should handle unregistering non-existent provider gracefully", () => { + const host = createTestHost() + + expect(() => { + host.unregisterWebviewProvider("non-existent") + }).not.toThrow() + }) + }) + + describe("webview ready state", () => { + describe("isInInitialSetup", () => { + it("should return true before webview is ready", () => { + const host = createTestHost() + expect(host.isInInitialSetup()).toBe(true) + }) + + it("should return false after markWebviewReady is called", () => { + const host = createTestHost() + host.markWebviewReady() + expect(host.isInInitialSetup()).toBe(false) + }) + }) + + describe("markWebviewReady", () => { + it("should set isWebviewReady to true", () => { + const host = createTestHost() + host.markWebviewReady() + expect(getPrivate(host, "isWebviewReady")).toBe(true) + }) + + it("should emit webviewReady event", () => { + const host = createTestHost() + const listener = vi.fn() + + host.on("webviewReady", listener) + host.markWebviewReady() + + expect(listener).toHaveBeenCalled() + }) + + it("should flush pending messages", () => { + const host = createTestHost() + const emitSpy = vi.spyOn(host, "emit") + + // Queue messages before ready + host.sendToExtension({ type: "test1" }) + host.sendToExtension({ type: "test2" }) + + // Mark ready (should flush) + host.markWebviewReady() + + // Check that webviewMessage events were emitted for pending messages + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test1" }) + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test2" }) + }) + }) + }) + + describe("sendToExtension", () => { + it("should queue message when webview not ready", () => { + const host = createTestHost() + const message = { type: "test" } + + host.sendToExtension(message) + + const pending = getPrivate(host, "pendingMessages") + expect(pending).toContain(message) + }) + + it("should emit webviewMessage event when webview is ready", () => { + const host = createTestHost() + const emitSpy = vi.spyOn(host, "emit") + const message = { type: "test" } + + host.markWebviewReady() + host.sendToExtension(message) + + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", message) + }) + + it("should not queue message when webview is ready", () => { + const host = createTestHost() + + host.markWebviewReady() + host.sendToExtension({ type: "test" }) + + const pending = getPrivate(host, "pendingMessages") + expect(pending).toHaveLength(0) + }) + }) + + describe("handleExtensionMessage", () => { + it("should route state messages to handleStateMessage", () => { + const host = createTestHost() + const handleStateSpy = spyOnPrivate(host, "handleStateMessage") + + callPrivate(host, "handleExtensionMessage", { type: "state", state: {} }) + + expect(handleStateSpy).toHaveBeenCalled() + }) + + it("should route messageUpdated to handleMessageUpdated", () => { + const host = createTestHost() + const handleMsgUpdatedSpy = spyOnPrivate(host, "handleMessageUpdated") + + callPrivate(host, "handleExtensionMessage", { type: "messageUpdated", clineMessage: {} }) + + expect(handleMsgUpdatedSpy).toHaveBeenCalled() + }) + + it("should route action messages to handleActionMessage", () => { + const host = createTestHost() + const handleActionSpy = spyOnPrivate(host, "handleActionMessage") + + callPrivate(host, "handleExtensionMessage", { type: "action", action: "test" }) + + expect(handleActionSpy).toHaveBeenCalled() + }) + + it("should route invoke messages to handleInvokeMessage", () => { + const host = createTestHost() + const handleInvokeSpy = spyOnPrivate(host, "handleInvokeMessage") + + callPrivate(host, "handleExtensionMessage", { type: "invoke", invoke: "test" }) + + expect(handleInvokeSpy).toHaveBeenCalled() + }) + }) + + describe("handleSayMessage", () => { + let host: ExtensionHost + let outputSpy: ReturnType + let outputErrorSpy: ReturnType + + beforeEach(() => { + host = createTestHost() + // Mock process.stdout.write and process.stderr.write which are used by output() and outputError() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + vi.spyOn(process.stderr, "write").mockImplementation(() => true) + // Spy on the output methods + outputSpy = spyOnPrivate(host, "output") + outputErrorSpy = spyOnPrivate(host, "outputError") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should emit taskComplete for completion_result", () => { + const emitSpy = vi.spyOn(host, "emit") + + callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false) + + expect(emitSpy).toHaveBeenCalledWith("taskComplete") + expect(outputSpy).toHaveBeenCalledWith("\n[task complete]", "Task done") + }) + + it("should output error messages without emitting taskError", () => { + const emitSpy = vi.spyOn(host, "emit") + + callPrivate(host, "handleSayMessage", 123, "error", "Something went wrong", false) + + // Errors are informational - they don't terminate the task + // The agent should decide what to do next + expect(emitSpy).not.toHaveBeenCalledWith("taskError", "Something went wrong") + expect(outputErrorSpy).toHaveBeenCalledWith("\n[error]", "Something went wrong") + }) + + it("should handle command_output messages", () => { + // Mock writeStream since command_output now uses it directly + const writeStreamSpy = spyOnPrivate(host, "writeStream") + + callPrivate(host, "handleSayMessage", 123, "command_output", "output text", false) + + // command_output now uses writeStream to bypass quiet mode + expect(writeStreamSpy).toHaveBeenCalledWith("\n[command output] ") + expect(writeStreamSpy).toHaveBeenCalledWith("output text") + expect(writeStreamSpy).toHaveBeenCalledWith("\n") + }) + + it("should handle tool messages", () => { + callPrivate(host, "handleSayMessage", 123, "tool", "tool usage", false) + + expect(outputSpy).toHaveBeenCalledWith("\n[tool]", "tool usage") + }) + + it("should skip already displayed complete messages", () => { + // First display + callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false) + outputSpy.mockClear() + + // Second display should be skipped + callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false) + + expect(outputSpy).not.toHaveBeenCalled() + }) + + it("should not output completion_result for partial messages", () => { + const emitSpy = vi.spyOn(host, "emit") + + // Partial message should not trigger output or taskComplete + callPrivate(host, "handleSayMessage", 123, "completion_result", "", true) + + expect(outputSpy).not.toHaveBeenCalled() + expect(emitSpy).not.toHaveBeenCalledWith("taskComplete") + }) + + it("should output completion_result text when complete message arrives after partial", () => { + const emitSpy = vi.spyOn(host, "emit") + + // First, a partial message with empty text (simulates streaming) + callPrivate(host, "handleSayMessage", 123, "completion_result", "", true) + outputSpy.mockClear() + emitSpy.mockClear() + + // Then, the complete message with the actual completion text + callPrivate(host, "handleSayMessage", 123, "completion_result", "Task completed successfully!", false) + + expect(outputSpy).toHaveBeenCalledWith("\n[task complete]", "Task completed successfully!") + expect(emitSpy).toHaveBeenCalledWith("taskComplete") + }) + + it("should track displayed messages", () => { + callPrivate(host, "handleSayMessage", 123, "tool", "test", false) + + const displayed = getPrivate>(host, "displayedMessages") + expect(displayed.has(123)).toBe(true) + }) + }) + + describe("handleAskMessage", () => { + let host: ExtensionHost + let outputSpy: ReturnType + + beforeEach(() => { + // Use nonInteractive mode for display-only behavior tests + host = createTestHost({ nonInteractive: true }) + // Mock process.stdout.write which is used by output() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + outputSpy = spyOnPrivate(host, "output") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should handle command type in non-interactive mode", () => { + callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) + + expect(outputSpy).toHaveBeenCalledWith("\n[command]", "ls -la") + }) + + it("should handle tool type with JSON parsing in non-interactive mode", () => { + const toolInfo = JSON.stringify({ tool: "write_file", path: "/test/file.txt" }) + + callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) + + expect(outputSpy).toHaveBeenCalledWith("\n[tool] write_file") + expect(outputSpy).toHaveBeenCalledWith(" path: /test/file.txt") + }) + + it("should handle tool type with content preview in non-interactive mode", () => { + const toolInfo = JSON.stringify({ + tool: "write_file", + content: "This is the content that will be written to the file. It might be long.", + }) + + callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) + + // Content is now shown (all tool parameters are displayed) + expect(outputSpy).toHaveBeenCalledWith("\n[tool] write_file") + expect(outputSpy).toHaveBeenCalledWith( + " content: This is the content that will be written to the file. It might be long.", + ) + }) + + it("should handle tool type with invalid JSON in non-interactive mode", () => { + callPrivate(host, "handleAskMessage", 123, "tool", "not json", false) + + expect(outputSpy).toHaveBeenCalledWith("\n[tool]", "not json") + }) + + it("should not display duplicate messages for same ts", () => { + const toolInfo = JSON.stringify({ tool: "read_file" }) + + // First call + callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) + outputSpy.mockClear() + + // Same ts - should be duplicate (already displayed) + callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) + + // Should not log again + expect(outputSpy).not.toHaveBeenCalled() + }) + + it("should handle other ask types in non-interactive mode", () => { + callPrivate(host, "handleAskMessage", 123, "question", "What is your name?", false) + + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What is your name?") + }) + + it("should skip partial messages", () => { + callPrivate(host, "handleAskMessage", 123, "command", "ls -la", true) + + // Partial messages should be skipped + expect(outputSpy).not.toHaveBeenCalled() + }) + }) + + describe("handleAskMessage - interactive mode", () => { + let host: ExtensionHost + let outputSpy: ReturnType + + beforeEach(() => { + // Default interactive mode + host = createTestHost({ nonInteractive: false }) + // Mock process.stdout.write which is used by output() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + outputSpy = spyOnPrivate(host, "output") + // Mock readline to prevent actual prompting + vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should mark ask as pending in interactive mode", () => { + // This will try to prompt, but we're testing the pendingAsks tracking + callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) + + const pendingAsks = getPrivate>(host, "pendingAsks") + expect(pendingAsks.has(123)).toBe(true) + }) + + it("should skip already pending asks", () => { + // First call - marks as pending + callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) + const callCount1 = outputSpy.mock.calls.length + + // Second call - should skip + callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) + const callCount2 = outputSpy.mock.calls.length + + // Should not have logged again + expect(callCount2).toBe(callCount1) + }) + }) + + describe("handleFollowupQuestion", () => { + let host: ExtensionHost + let outputSpy: ReturnType + + beforeEach(() => { + host = createTestHost({ nonInteractive: false }) + // Mock process.stdout.write which is used by output() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + outputSpy = spyOnPrivate(host, "output") + // Mock readline to prevent actual prompting + vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should parse followup question JSON with suggestion objects containing answer and mode", async () => { + // This is the format from AskFollowupQuestionTool + // { question: "...", suggest: [{ answer: "text", mode: "code" }, ...] } + const text = JSON.stringify({ + question: "What would you like to do?", + suggest: [ + { answer: "Write code", mode: "code" }, + { answer: "Debug issue", mode: "debug" }, + { answer: "Just explain", mode: null }, + ], + }) + + // Call the handler (it will try to prompt but we just want to test parsing) + callPrivate(host, "handleFollowupQuestion", 123, text) + + // Should display the question + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What would you like to do?") + + // Should display suggestions with answer text and mode hints + expect(outputSpy).toHaveBeenCalledWith("\nSuggested answers:") + expect(outputSpy).toHaveBeenCalledWith(" 1. Write code (mode: code)") + expect(outputSpy).toHaveBeenCalledWith(" 2. Debug issue (mode: debug)") + expect(outputSpy).toHaveBeenCalledWith(" 3. Just explain") + }) + + it("should handle followup question with suggestions that have no mode", async () => { + const text = JSON.stringify({ + question: "What path?", + suggest: [{ answer: "./src/file.ts" }, { answer: "./lib/other.ts" }], + }) + + callPrivate(host, "handleFollowupQuestion", 123, text) + + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What path?") + expect(outputSpy).toHaveBeenCalledWith(" 1. ./src/file.ts") + expect(outputSpy).toHaveBeenCalledWith(" 2. ./lib/other.ts") + }) + + it("should handle plain text (non-JSON) as the question", async () => { + callPrivate(host, "handleFollowupQuestion", 123, "What is your name?") + + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What is your name?") + }) + + it("should handle empty suggestions array", async () => { + const text = JSON.stringify({ + question: "Tell me more", + suggest: [], + }) + + callPrivate(host, "handleFollowupQuestion", 123, text) + + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "Tell me more") + // Should not show "Suggested answers:" if array is empty + expect(outputSpy).not.toHaveBeenCalledWith("\nSuggested answers:") + }) + }) + + describe("handleFollowupQuestionWithTimeout", () => { + let host: ExtensionHost + let outputSpy: ReturnType + const originalIsTTY = process.stdin.isTTY + + beforeEach(() => { + // Non-interactive mode uses the timeout variant + host = createTestHost({ nonInteractive: true }) + // Mock process.stdout.write which is used by output() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + outputSpy = spyOnPrivate(host, "output") + // Mock stdin - set isTTY to false so setRawMode is not called + Object.defineProperty(process.stdin, "isTTY", { value: false, writable: true }) + vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "removeListener").mockImplementation(() => process.stdin) + }) + + afterEach(() => { + vi.restoreAllMocks() + Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, writable: true }) + }) + + it("should parse followup question JSON and display question with suggestions", () => { + const text = JSON.stringify({ + question: "What would you like to do?", + suggest: [ + { answer: "Option A", mode: "code" }, + { answer: "Option B", mode: null }, + ], + }) + + // Call the handler - it will display the question and start the timeout + callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text) + + // Should display the question + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What would you like to do?") + + // Should display suggestions + expect(outputSpy).toHaveBeenCalledWith("\nSuggested answers:") + expect(outputSpy).toHaveBeenCalledWith(" 1. Option A (mode: code)") + expect(outputSpy).toHaveBeenCalledWith(" 2. Option B") + }) + + it("should handle non-JSON text as plain question", () => { + callPrivate(host, "handleFollowupQuestionWithTimeout", 123, "Plain question text") + + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "Plain question text") + }) + + it("should include auto-select hint in prompt when suggestions exist", () => { + const stdoutWriteSpy = vi.spyOn(process.stdout, "write") + const text = JSON.stringify({ + question: "Choose one", + suggest: [{ answer: "First option" }], + }) + + callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text) + + // Should show prompt with timeout hint + expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 10s")) + }) + }) + + describe("handleAskMessageNonInteractive - followup handling", () => { + let host: ExtensionHost + let _outputSpy: ReturnType + let handleFollowupTimeoutSpy: ReturnType + const originalIsTTY = process.stdin.isTTY + + beforeEach(() => { + host = createTestHost({ nonInteractive: true }) + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + _outputSpy = spyOnPrivate(host, "output") + handleFollowupTimeoutSpy = spyOnPrivate(host, "handleFollowupQuestionWithTimeout") + // Mock stdin - set isTTY to false so setRawMode is not called + Object.defineProperty(process.stdin, "isTTY", { value: false, writable: true }) + vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "removeListener").mockImplementation(() => process.stdin) + }) + + afterEach(() => { + vi.restoreAllMocks() + Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, writable: true }) + }) + + it("should call handleFollowupQuestionWithTimeout for followup asks in non-interactive mode", () => { + const text = JSON.stringify({ + question: "What to do?", + suggest: [{ answer: "Do something" }], + }) + + callPrivate(host, "handleAskMessageNonInteractive", 123, "followup", text) + + expect(handleFollowupTimeoutSpy).toHaveBeenCalledWith(123, text) + }) + + it("should add ts to pendingAsks for followup in non-interactive mode", () => { + const text = JSON.stringify({ + question: "What to do?", + suggest: [{ answer: "Do something" }], + }) + + callPrivate(host, "handleAskMessageNonInteractive", 123, "followup", text) + + const pendingAsks = getPrivate>(host, "pendingAsks") + expect(pendingAsks.has(123)).toBe(true) + }) + }) + + describe("streamContent", () => { + let host: ExtensionHost + let writeStreamSpy: ReturnType + + beforeEach(() => { + host = createTestHost() + // Mock process.stdout.write + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + writeStreamSpy = spyOnPrivate(host, "writeStream") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should output header and text for new messages", () => { + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + + expect(writeStreamSpy).toHaveBeenCalledWith("\n[Test] ") + expect(writeStreamSpy).toHaveBeenCalledWith("Hello") + }) + + it("should compute delta for growing text", () => { + // First call - establishes baseline + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + writeStreamSpy.mockClear() + + // Second call - should only output delta + callPrivate(host, "streamContent", 123, "Hello World", "[Test]") + + expect(writeStreamSpy).toHaveBeenCalledWith(" World") + }) + + it("should skip when text has not grown", () => { + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + writeStreamSpy.mockClear() + + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + + expect(writeStreamSpy).not.toHaveBeenCalled() + }) + + it("should skip when text does not match prefix", () => { + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + writeStreamSpy.mockClear() + + // Different text entirely + callPrivate(host, "streamContent", 123, "Goodbye", "[Test]") + + expect(writeStreamSpy).not.toHaveBeenCalled() + }) + + it("should track currently streaming ts", () => { + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + + expect(getPrivate(host, "currentlyStreamingTs")).toBe(123) + }) + }) + + describe("finishStream", () => { + let host: ExtensionHost + let writeStreamSpy: ReturnType + + beforeEach(() => { + host = createTestHost() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + writeStreamSpy = spyOnPrivate(host, "writeStream") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should add newline when finishing current stream", () => { + // Set up streaming state + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + writeStreamSpy.mockClear() + + callPrivate(host, "finishStream", 123) + + expect(writeStreamSpy).toHaveBeenCalledWith("\n") + expect(getPrivate(host, "currentlyStreamingTs")).toBeNull() + }) + + it("should not add newline for different ts", () => { + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + writeStreamSpy.mockClear() + + callPrivate(host, "finishStream", 456) + + expect(writeStreamSpy).not.toHaveBeenCalled() + }) + }) + + describe("quiet mode", () => { + describe("setupQuietMode", () => { + it("should not modify console when quiet mode disabled", () => { + const host = createTestHost({ quiet: false }) + const originalLog = console.log + + callPrivate(host, "setupQuietMode") + + expect(console.log).toBe(originalLog) + }) + + it("should suppress console.log, warn, debug, info when enabled", () => { + const host = createTestHost({ quiet: true }) + const originalLog = console.log + + callPrivate(host, "setupQuietMode") + + // These should be no-ops now (different from original) + expect(console.log).not.toBe(originalLog) + + // Verify they are actually no-ops by calling them (should not throw) + expect(() => console.log("test")).not.toThrow() + expect(() => console.warn("test")).not.toThrow() + expect(() => console.debug("test")).not.toThrow() + expect(() => console.info("test")).not.toThrow() + + // Restore for other tests + callPrivate(host, "restoreConsole") + }) + + it("should preserve console.error", () => { + const host = createTestHost({ quiet: true }) + const originalError = console.error + + callPrivate(host, "setupQuietMode") + + expect(console.error).toBe(originalError) + + callPrivate(host, "restoreConsole") + }) + + it("should store original console methods", () => { + const host = createTestHost({ quiet: true }) + const originalLog = console.log + + callPrivate(host, "setupQuietMode") + + const stored = getPrivate<{ log: typeof console.log }>(host, "originalConsole") + expect(stored.log).toBe(originalLog) + + callPrivate(host, "restoreConsole") + }) + }) + + describe("restoreConsole", () => { + it("should restore original console methods", () => { + const host = createTestHost({ quiet: true }) + const originalLog = console.log + + callPrivate(host, "setupQuietMode") + callPrivate(host, "restoreConsole") + + expect(console.log).toBe(originalLog) + }) + + it("should handle case where console was not suppressed", () => { + const host = createTestHost({ quiet: false }) + + expect(() => { + callPrivate(host, "restoreConsole") + }).not.toThrow() + }) + }) + + describe("suppressNodeWarnings", () => { + it("should suppress process.emitWarning", () => { + const host = createTestHost() + const originalEmitWarning = process.emitWarning + + callPrivate(host, "suppressNodeWarnings") + + expect(process.emitWarning).not.toBe(originalEmitWarning) + + // Restore + callPrivate(host, "restoreConsole") + }) + }) + }) + + describe("dispose", () => { + let host: ExtensionHost + + beforeEach(() => { + host = createTestHost() + }) + + it("should remove message listener", async () => { + const listener = vi.fn() + ;(host as unknown as Record).messageListener = listener + host.on("extensionWebviewMessage", listener) + + await host.dispose() + + expect(getPrivate(host, "messageListener")).toBeNull() + }) + + it("should call extension deactivate if available", async () => { + const deactivateMock = vi.fn() + ;(host as unknown as Record).extensionModule = { + deactivate: deactivateMock, + } + + await host.dispose() + + expect(deactivateMock).toHaveBeenCalled() + }) + + it("should clear vscode reference", async () => { + ;(host as unknown as Record).vscode = { context: {} } + + await host.dispose() + + expect(getPrivate(host, "vscode")).toBeNull() + }) + + it("should clear extensionModule reference", async () => { + ;(host as unknown as Record).extensionModule = {} + + await host.dispose() + + expect(getPrivate(host, "extensionModule")).toBeNull() + }) + + it("should clear webviewProviders", async () => { + host.registerWebviewProvider("test", {}) + + await host.dispose() + + const providers = getPrivate>(host, "webviewProviders") + expect(providers.size).toBe(0) + }) + + it("should delete global vscode", async () => { + ;(global as Record).vscode = {} + + await host.dispose() + + expect((global as Record).vscode).toBeUndefined() + }) + + it("should delete global __extensionHost", async () => { + ;(global as Record).__extensionHost = {} + + await host.dispose() + + expect((global as Record).__extensionHost).toBeUndefined() + }) + + it("should restore console if it was suppressed", async () => { + const restoreConsoleSpy = spyOnPrivate(host, "restoreConsole") + + await host.dispose() + + expect(restoreConsoleSpy).toHaveBeenCalled() + }) + }) + + describe("waitForCompletion", () => { + it("should resolve when taskComplete is emitted", async () => { + const host = createTestHost() + + const promise = callPrivate>(host, "waitForCompletion") + + // Emit completion after a short delay + setTimeout(() => host.emit("taskComplete"), 10) + + await expect(promise).resolves.toBeUndefined() + }) + + it("should reject when taskError is emitted", async () => { + const host = createTestHost() + + const promise = callPrivate>(host, "waitForCompletion") + + setTimeout(() => host.emit("taskError", "Test error"), 10) + + await expect(promise).rejects.toThrow("Test error") + }) + + it("should timeout after configured duration", async () => { + const host = createTestHost() + + // Use fake timers for this test + vi.useFakeTimers() + + const promise = callPrivate>(host, "waitForCompletion") + + // Fast-forward past the timeout (10 minutes) + vi.advanceTimersByTime(10 * 60 * 1000 + 1) + + await expect(promise).rejects.toThrow("Task timed out") + + vi.useRealTimers() + }) + }) +}) diff --git a/apps/cli/src/__tests__/integration.test.ts b/apps/cli/src/__tests__/integration.test.ts new file mode 100644 index 0000000000..158438decb --- /dev/null +++ b/apps/cli/src/__tests__/integration.test.ts @@ -0,0 +1,144 @@ +/** + * Integration tests for CLI + * + * These tests require a valid OPENROUTER_API_KEY environment variable. + * They will be skipped if the API key is not available. + * + * Run with: OPENROUTER_API_KEY=sk-or-v1-... pnpm test + */ + +import { ExtensionHost } from "../extension-host.js" +import path from "path" +import fs from "fs" +import os from "os" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY +const hasApiKey = !!OPENROUTER_API_KEY + +// Find the extension path - we need a built extension for integration tests +function findExtensionPath(): string | null { + // From apps/cli/src/__tests__, go up to monorepo root then to src/dist + const monorepoPath = path.resolve(__dirname, "../../../../src/dist") + if (fs.existsSync(path.join(monorepoPath, "extension.js"))) { + return monorepoPath + } + // Also try from the apps/cli level + const altPath = path.resolve(__dirname, "../../../src/dist") + if (fs.existsSync(path.join(altPath, "extension.js"))) { + return altPath + } + return null +} + +const extensionPath = findExtensionPath() +const hasExtension = !!extensionPath + +// Create a temporary workspace directory for tests +function createTempWorkspace(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "roo-cli-test-")) + return tempDir +} + +// Clean up temporary workspace +function cleanupWorkspace(workspacePath: string): void { + try { + fs.rmSync(workspacePath, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } +} + +describe.skipIf(!hasApiKey || !hasExtension)( + "CLI Integration Tests (requires OPENROUTER_API_KEY and built extension)", + () => { + let workspacePath: string + let host: ExtensionHost + + beforeAll(() => { + console.log("Integration tests running with:") + console.log(` - API Key: ${OPENROUTER_API_KEY?.substring(0, 12)}...`) + console.log(` - Extension Path: ${extensionPath}`) + }) + + beforeEach(() => { + workspacePath = createTempWorkspace() + }) + + afterEach(async () => { + if (host) { + await host.dispose() + } + cleanupWorkspace(workspacePath) + }) + + /** + * Main integration test - tests the complete end-to-end flow + * + * NOTE: Due to the extension using singletons (TelemetryService, etc.), + * only one integration test can run per process. This single test covers + * the main functionality: activation, task execution, completion, and disposal. + */ + it("should complete end-to-end task execution with proper lifecycle", async () => { + host = new ExtensionHost({ + mode: "code", + apiProvider: "openrouter", + apiKey: OPENROUTER_API_KEY!, + model: "anthropic/claude-haiku-4.5", // Use fast, cheap model for tests. + workspacePath, + extensionPath: extensionPath!, + quiet: true, + }) + + // Test activation + await host.activate() + + // Track state messages + const stateMessages: unknown[] = [] + host.on("extensionWebviewMessage", (msg: Record) => { + if (msg.type === "state") { + stateMessages.push(msg) + } + }) + + // Test task execution with completion + // Note: runTask internally waits for webview to be ready before sending messages + await expect(host.runTask("Say hello in exactly 5 words")).resolves.toBeUndefined() + + // After task completes, webview should have been ready + expect(host.isInInitialSetup()).toBe(false) + + // Verify we received state updates + expect(stateMessages.length).toBeGreaterThan(0) + + // Test disposal + await host.dispose() + expect((global as Record).vscode).toBeUndefined() + expect((global as Record).__extensionHost).toBeUndefined() + }, 120000) // 2 minute timeout + }, +) + +// Additional test to verify skip behavior +describe("Integration test skip behavior", () => { + it("should have OPENROUTER_API_KEY check", () => { + if (hasApiKey) { + console.log("OPENROUTER_API_KEY is set, integration tests will run") + } else { + console.log("OPENROUTER_API_KEY is not set, integration tests will be skipped") + } + expect(true).toBe(true) // Always passes + }) + + it("should have extension check", () => { + if (hasExtension) { + console.log(`Extension found at: ${extensionPath}`) + } else { + console.log("Extension not found, integration tests will be skipped") + } + expect(true).toBe(true) // Always passes + }) +}) diff --git a/apps/cli/src/__tests__/utils.test.ts b/apps/cli/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..34ce825463 --- /dev/null +++ b/apps/cli/src/__tests__/utils.test.ts @@ -0,0 +1,119 @@ +/** + * Unit tests for CLI utility functions + */ + +import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js" +import fs from "fs" +import path from "path" + +// Mock fs module +vi.mock("fs") + +describe("getEnvVarName", () => { + it.each([ + ["anthropic", "ANTHROPIC_API_KEY"], + ["openai", "OPENAI_API_KEY"], + ["openrouter", "OPENROUTER_API_KEY"], + ["google", "GOOGLE_API_KEY"], + ["gemini", "GOOGLE_API_KEY"], + ["bedrock", "AWS_ACCESS_KEY_ID"], + ["ollama", "OLLAMA_API_KEY"], + ["mistral", "MISTRAL_API_KEY"], + ["deepseek", "DEEPSEEK_API_KEY"], + ])("should return %s for %s provider", (provider, expectedEnvVar) => { + expect(getEnvVarName(provider)).toBe(expectedEnvVar) + }) + + it("should handle case-insensitive provider names", () => { + expect(getEnvVarName("ANTHROPIC")).toBe("ANTHROPIC_API_KEY") + expect(getEnvVarName("Anthropic")).toBe("ANTHROPIC_API_KEY") + expect(getEnvVarName("OpenRouter")).toBe("OPENROUTER_API_KEY") + }) + + it("should return uppercase provider name with _API_KEY suffix for unknown providers", () => { + expect(getEnvVarName("custom")).toBe("CUSTOM_API_KEY") + expect(getEnvVarName("myProvider")).toBe("MYPROVIDER_API_KEY") + }) +}) + +describe("getApiKeyFromEnv", () => { + const originalEnv = process.env + + beforeEach(() => { + // Reset process.env before each test + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it("should return API key from environment variable for anthropic", () => { + process.env.ANTHROPIC_API_KEY = "test-anthropic-key" + expect(getApiKeyFromEnv("anthropic")).toBe("test-anthropic-key") + }) + + it("should return API key from environment variable for openrouter", () => { + process.env.OPENROUTER_API_KEY = "test-openrouter-key" + expect(getApiKeyFromEnv("openrouter")).toBe("test-openrouter-key") + }) + + it("should return API key from environment variable for openai", () => { + process.env.OPENAI_API_KEY = "test-openai-key" + expect(getApiKeyFromEnv("openai")).toBe("test-openai-key") + }) + + it("should return undefined when API key is not set", () => { + delete process.env.ANTHROPIC_API_KEY + expect(getApiKeyFromEnv("anthropic")).toBeUndefined() + }) + + it("should handle custom provider names", () => { + process.env.CUSTOM_API_KEY = "test-custom-key" + expect(getApiKeyFromEnv("custom")).toBe("test-custom-key") + }) + + it("should handle case-insensitive provider lookup", () => { + process.env.ANTHROPIC_API_KEY = "test-key" + expect(getApiKeyFromEnv("ANTHROPIC")).toBe("test-key") + }) +}) + +describe("getDefaultExtensionPath", () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it("should return monorepo path when extension.js exists there", () => { + const mockDirname = "/test/apps/cli/dist" + const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist") + + vi.mocked(fs.existsSync).mockReturnValue(true) + + const result = getDefaultExtensionPath(mockDirname) + + expect(result).toBe(expectedMonorepoPath) + expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js")) + }) + + it("should return package path when extension.js does not exist in monorepo path", () => { + const mockDirname = "/test/apps/cli/dist" + const expectedPackagePath = path.resolve(mockDirname, "../extension") + + vi.mocked(fs.existsSync).mockReturnValue(false) + + const result = getDefaultExtensionPath(mockDirname) + + expect(result).toBe(expectedPackagePath) + }) + + it("should check monorepo path first", () => { + const mockDirname = "/some/path" + vi.mocked(fs.existsSync).mockReturnValue(false) + + getDefaultExtensionPath(mockDirname) + + const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist") + expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js")) + }) +}) diff --git a/apps/cli/src/extension-host.ts b/apps/cli/src/extension-host.ts new file mode 100644 index 0000000000..3396386924 --- /dev/null +++ b/apps/cli/src/extension-host.ts @@ -0,0 +1,1663 @@ +/** + * ExtensionHost - Loads and runs the Roo Code extension in CLI mode + * + * This class is responsible for: + * 1. Creating the vscode-shim mock + * 2. Loading the extension bundle via require() + * 3. Activating the extension + * 4. Managing bidirectional message flow between CLI and extension + */ + +import { EventEmitter } from "events" +import { createRequire } from "module" +import path from "path" +import { fileURLToPath } from "url" +import fs from "fs" +import readline from "readline" + +import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim" +import { ProviderName, ReasoningEffortExtended, RooCodeSettings } from "@roo-code/types" + +// Get the CLI package root directory (for finding node_modules/@vscode/ripgrep) +// When bundled, import.meta.url points to dist/index.js, so go up to package root +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const CLI_PACKAGE_ROOT = path.resolve(__dirname, "..") + +export interface ExtensionHostOptions { + mode: string + reasoningEffort?: ReasoningEffortExtended | "disabled" + apiProvider: ProviderName + apiKey?: string + model: string + workspacePath: string + extensionPath: string + verbose?: boolean + quiet?: boolean + nonInteractive?: boolean +} + +interface ExtensionModule { + activate: (context: unknown) => Promise + deactivate?: () => Promise +} + +/** + * Local interface for webview provider (matches VSCode API) + */ +interface WebviewViewProvider { + resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise +} + +export class ExtensionHost extends EventEmitter { + private vscode: ReturnType | null = null + private extensionModule: ExtensionModule | null = null + private extensionAPI: unknown = null + private webviewProviders: Map = new Map() + private options: ExtensionHostOptions + private isWebviewReady = false + private pendingMessages: unknown[] = [] + private messageListener: ((message: unknown) => void) | null = null + + private originalConsole: { + log: typeof console.log + warn: typeof console.warn + error: typeof console.error + debug: typeof console.debug + info: typeof console.info + } | null = null + + private originalProcessEmitWarning: typeof process.emitWarning | null = null + + // Track pending asks that need a response (by ts) + private pendingAsks: Set = new Set() + + // Readline interface for interactive prompts + private rl: readline.Interface | null = null + + // Track displayed messages by ts to avoid duplicates and show updates + private displayedMessages: Map = new Map() + + // Track streamed content by ts for delta computation + private streamedContent: Map = new Map() + + // Track message processing for verbose debug output + private processedMessageCount = 0 + + // Track if we're currently streaming a message (to manage newlines) + private currentlyStreamingTs: number | null = null + + constructor(options: ExtensionHostOptions) { + super() + this.options = options + } + + private log(...args: unknown[]): void { + if (this.options.verbose) { + // Use original console if available to avoid quiet mode suppression + const logFn = this.originalConsole?.log || console.log + logFn("[ExtensionHost]", ...args) + } + } + + /** + * Suppress Node.js warnings (like MaxListenersExceededWarning) + * This is called regardless of quiet mode to prevent warnings from interrupting output + */ + private suppressNodeWarnings(): void { + // Suppress process warnings (like MaxListenersExceededWarning) + this.originalProcessEmitWarning = process.emitWarning + process.emitWarning = () => {} + + // Also suppress via the warning event handler + process.on("warning", () => {}) + } + + /** + * Suppress console output from the extension when quiet mode is enabled. + * This intercepts console.log, console.warn, console.info, console.debug + * but allows console.error through for critical errors. + */ + private setupQuietMode(): void { + if (!this.options.quiet) { + return + } + + // Save original console methods + this.originalConsole = { + log: console.log, + warn: console.warn, + error: console.error, + debug: console.debug, + info: console.info, + } + + // Replace with no-op functions (except error) + console.log = () => {} + console.warn = () => {} + console.debug = () => {} + console.info = () => {} + // Keep console.error for critical errors + } + + /** + * Restore original console methods and process.emitWarning + */ + private restoreConsole(): void { + if (this.originalConsole) { + console.log = this.originalConsole.log + console.warn = this.originalConsole.warn + console.error = this.originalConsole.error + console.debug = this.originalConsole.debug + console.info = this.originalConsole.info + this.originalConsole = null + } + + if (this.originalProcessEmitWarning) { + process.emitWarning = this.originalProcessEmitWarning + this.originalProcessEmitWarning = null + } + } + + async activate(): Promise { + this.log("Activating extension...") + + // Suppress Node.js warnings (like MaxListenersExceededWarning) before anything else + this.suppressNodeWarnings() + + // Set up quiet mode before loading extension + this.setupQuietMode() + + // Verify extension path exists + const bundlePath = path.join(this.options.extensionPath, "extension.js") + if (!fs.existsSync(bundlePath)) { + this.restoreConsole() + throw new Error(`Extension bundle not found at: ${bundlePath}`) + } + + // 1. Create VSCode API mock + this.log("Creating VSCode API mock...") + this.log("Using appRoot:", CLI_PACKAGE_ROOT) + this.vscode = createVSCodeAPI( + this.options.extensionPath, + this.options.workspacePath, + undefined, // identity + { appRoot: CLI_PACKAGE_ROOT }, // options - point appRoot to CLI package for ripgrep + ) + + // 2. Set global vscode reference for the extension + ;(global as Record).vscode = this.vscode + + // 3. Set up __extensionHost global for webview registration + // This is used by WindowAPI.registerWebviewViewProvider + ;(global as Record).__extensionHost = this + + // 4. Set up module resolution to intercept require('vscode') + const require = createRequire(import.meta.url) + const Module = require("module") + const originalResolve = Module._resolveFilename + + Module._resolveFilename = function (request: string, parent: unknown, isMain: boolean, options: unknown) { + if (request === "vscode") { + return "vscode-mock" + } + return originalResolve.call(this, request, parent, isMain, options) + } + + // Add the mock to require.cache + // Use 'as unknown as' to satisfy TypeScript's Module type requirements + require.cache["vscode-mock"] = { + id: "vscode-mock", + filename: "vscode-mock", + loaded: true, + exports: this.vscode, + children: [], + paths: [], + path: "", + isPreloading: false, + parent: null, + require: require, + } as unknown as NodeJS.Module + + this.log("Loading extension bundle from:", bundlePath) + + // 5. Load extension bundle + try { + this.extensionModule = require(bundlePath) as ExtensionModule + } catch (error) { + // Restore module resolution before throwing + Module._resolveFilename = originalResolve + throw new Error( + `Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + // 6. Restore module resolution + Module._resolveFilename = originalResolve + + this.log("Activating extension...") + + // 7. Activate extension + try { + this.extensionAPI = await this.extensionModule.activate(this.vscode.context) + this.log("Extension activated successfully") + } catch (error) { + throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`) + } + } + + /** + * Called by WindowAPI.registerWebviewViewProvider + * This is triggered when the extension registers its sidebar webview provider + */ + registerWebviewProvider(viewId: string, provider: WebviewViewProvider): void { + this.log(`Webview provider registered: ${viewId}`) + this.webviewProviders.set(viewId, provider) + + // The WindowAPI will call resolveWebviewView automatically + // We don't need to do anything here + } + + /** + * Called when a webview provider is disposed + */ + unregisterWebviewProvider(viewId: string): void { + this.log(`Webview provider unregistered: ${viewId}`) + this.webviewProviders.delete(viewId) + } + + /** + * Returns true during initial extension setup + * Used to prevent the extension from aborting tasks during initialization + */ + isInInitialSetup(): boolean { + return !this.isWebviewReady + } + + /** + * Called by WindowAPI after resolveWebviewView completes + * This indicates the webview is ready to receive messages + */ + markWebviewReady(): void { + this.log("Webview marked as ready") + this.isWebviewReady = true + this.emit("webviewReady") + + // Flush any pending messages + this.flushPendingMessages() + } + + /** + * Send any messages that were queued before the webview was ready + */ + private flushPendingMessages(): void { + if (this.pendingMessages.length > 0) { + this.log(`Flushing ${this.pendingMessages.length} pending messages`) + for (const message of this.pendingMessages) { + this.emit("webviewMessage", message) + } + this.pendingMessages = [] + } + } + + /** + * Send a message to the extension (simulating webview -> extension communication). + */ + sendToExtension(message: unknown): void { + if (!this.isWebviewReady) { + this.log("Queueing message (webview not ready):", message) + this.pendingMessages.push(message) + return + } + + this.log("Sending message to extension:", message) + this.emit("webviewMessage", message) + } + + private applyRuntimeSettings(settings: RooCodeSettings): void { + if (this.options.mode) { + settings.mode = this.options.mode + } + + if (this.options.reasoningEffort) { + if (this.options.reasoningEffort === "disabled") { + settings.enableReasoningEffort = false + } else { + settings.enableReasoningEffort = true + settings.reasoningEffort = this.options.reasoningEffort + } + } + + // Update vscode-shim runtime configuration so + // vscode.workspace.getConfiguration() returns correct values. + setRuntimeConfigValues("roo-cline", settings as Record) + } + + /** + * Build the provider-specific API configuration + * Each provider uses different field names for API key and model + */ + private buildApiConfiguration(): RooCodeSettings { + const provider = this.options.apiProvider || "anthropic" + const apiKey = this.options.apiKey + const model = this.options.model + + // Base config with provider. + const config: RooCodeSettings = { apiProvider: provider } + + // Map provider to the correct API key and model field names. + switch (provider) { + case "anthropic": + if (apiKey) config.apiKey = apiKey + if (model) config.apiModelId = model + break + + case "openrouter": + if (apiKey) config.openRouterApiKey = apiKey + if (model) config.openRouterModelId = model + break + + case "gemini": + if (apiKey) config.geminiApiKey = apiKey + if (model) config.apiModelId = model + break + + case "openai-native": + if (apiKey) config.openAiNativeApiKey = apiKey + if (model) config.apiModelId = model + break + + case "openai": + if (apiKey) config.openAiApiKey = apiKey + if (model) config.openAiModelId = model + break + + case "mistral": + if (apiKey) config.mistralApiKey = apiKey + if (model) config.apiModelId = model + break + + case "deepseek": + if (apiKey) config.deepSeekApiKey = apiKey + if (model) config.apiModelId = model + break + + case "xai": + if (apiKey) config.xaiApiKey = apiKey + if (model) config.apiModelId = model + break + + case "groq": + if (apiKey) config.groqApiKey = apiKey + if (model) config.apiModelId = model + break + + case "fireworks": + if (apiKey) config.fireworksApiKey = apiKey + if (model) config.apiModelId = model + break + + case "cerebras": + if (apiKey) config.cerebrasApiKey = apiKey + if (model) config.apiModelId = model + break + + case "sambanova": + if (apiKey) config.sambaNovaApiKey = apiKey + if (model) config.apiModelId = model + break + + case "ollama": + if (apiKey) config.ollamaApiKey = apiKey + if (model) config.ollamaModelId = model + break + + case "lmstudio": + if (model) config.lmStudioModelId = model + break + + case "litellm": + if (apiKey) config.litellmApiKey = apiKey + if (model) config.litellmModelId = model + break + + case "huggingface": + if (apiKey) config.huggingFaceApiKey = apiKey + if (model) config.huggingFaceModelId = model + break + + case "chutes": + if (apiKey) config.chutesApiKey = apiKey + if (model) config.apiModelId = model + break + + case "featherless": + if (apiKey) config.featherlessApiKey = apiKey + if (model) config.apiModelId = model + break + + case "unbound": + if (apiKey) config.unboundApiKey = apiKey + if (model) config.unboundModelId = model + break + + case "requesty": + if (apiKey) config.requestyApiKey = apiKey + if (model) config.requestyModelId = model + break + + case "deepinfra": + if (apiKey) config.deepInfraApiKey = apiKey + if (model) config.deepInfraModelId = model + break + + case "vercel-ai-gateway": + if (apiKey) config.vercelAiGatewayApiKey = apiKey + if (model) config.vercelAiGatewayModelId = model + break + + case "zai": + if (apiKey) config.zaiApiKey = apiKey + if (model) config.apiModelId = model + break + + case "baseten": + if (apiKey) config.basetenApiKey = apiKey + if (model) config.apiModelId = model + break + + case "doubao": + if (apiKey) config.doubaoApiKey = apiKey + if (model) config.apiModelId = model + break + + case "moonshot": + if (apiKey) config.moonshotApiKey = apiKey + if (model) config.apiModelId = model + break + + case "minimax": + if (apiKey) config.minimaxApiKey = apiKey + if (model) config.apiModelId = model + break + + case "io-intelligence": + if (apiKey) config.ioIntelligenceApiKey = apiKey + if (model) config.ioIntelligenceModelId = model + break + + default: + // Default to apiKey and apiModelId for unknown providers. + if (apiKey) config.apiKey = apiKey + if (model) config.apiModelId = model + } + + return config + } + + /** + * Run a task with the given prompt + */ + async runTask(prompt: string): Promise { + this.log("Running task:", prompt) + + // Wait for webview to be ready + if (!this.isWebviewReady) { + this.log("Waiting for webview to be ready...") + await new Promise((resolve) => { + this.once("webviewReady", resolve) + }) + } + + // Set up message listener for extension responses + this.setupMessageListener() + + // Configure approval settings based on mode + // In non-interactive mode (-y flag), enable auto-approval for everything + // In interactive mode (default), we'll prompt the user for each action + if (this.options.nonInteractive) { + this.log("Non-interactive mode: enabling auto-approval settings...") + + const settings: RooCodeSettings = { + autoApprovalEnabled: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: true, + alwaysAllowWrite: true, + alwaysAllowWriteOutsideWorkspace: true, + alwaysAllowWriteProtected: false, // Keep protected files safe. + alwaysAllowBrowser: true, + alwaysAllowMcp: true, + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + alwaysAllowExecute: true, + alwaysAllowFollowupQuestions: true, + // Allow all commands with wildcard (required for command auto-approval). + allowedCommands: ["*"], + commandExecutionTimeout: 20, + } + + this.applyRuntimeSettings(settings) + this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) + await new Promise((resolve) => setTimeout(resolve, 100)) + } else { + this.log("Interactive mode: user will be prompted for approvals...") + const settings: RooCodeSettings = { autoApprovalEnabled: false } + this.applyRuntimeSettings(settings) + this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + if (this.options.apiKey) { + this.sendToExtension({ type: "updateSettings", updatedSettings: this.buildApiConfiguration() }) + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + this.sendToExtension({ type: "newTask", text: prompt }) + await this.waitForCompletion() + } + + /** + * Set up listener for messages from the extension + */ + private setupMessageListener(): void { + this.messageListener = (message: unknown) => { + this.handleExtensionMessage(message) + } + + this.on("extensionWebviewMessage", this.messageListener) + } + + /** + * Handle messages from the extension + */ + private handleExtensionMessage(message: unknown): void { + const msg = message as Record + + if (this.options.verbose) { + this.log("Received message from extension:", JSON.stringify(msg, null, 2)) + } + + // Handle different message types + switch (msg.type) { + case "state": + this.handleStateMessage(msg) + break + + case "messageUpdated": + // This is the streaming update - handle individual message updates + this.handleMessageUpdated(msg) + break + + case "action": + this.handleActionMessage(msg) + break + + case "invoke": + this.handleInvokeMessage(msg) + break + + default: + // Log unknown message types in verbose mode + if (this.options.verbose) { + this.log("Unknown message type:", msg.type) + } + } + } + + /** + * Output a message to the user (bypasses quiet mode) + * Use this for all user-facing output instead of console.log + */ + private output(...args: unknown[]): void { + const text = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") + process.stdout.write(text + "\n") + } + + /** + * Output an error message to the user (bypasses quiet mode) + * Use this for all user-facing errors instead of console.error + */ + private outputError(...args: unknown[]): void { + const text = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") + process.stderr.write(text + "\n") + } + + /** + * Handle state update messages from the extension + */ + private handleStateMessage(msg: Record): void { + const state = msg.state as Record | undefined + if (!state) return + + const clineMessages = state.clineMessages as Array> | undefined + + if (clineMessages && clineMessages.length > 0) { + // Track message processing for verbose debug output + this.processedMessageCount++ + + // Verbose: log state update summary + if (this.options.verbose) { + this.log(`State update #${this.processedMessageCount}: ${clineMessages.length} messages`) + } + + // Process all messages to find new or updated ones + for (const message of clineMessages) { + if (!message) continue + + const ts = message.ts as number | undefined + const isPartial = message.partial as boolean | undefined + const text = message.text as string + const type = message.type as string + const say = message.say as string | undefined + const ask = message.ask as string | undefined + + if (!ts) continue + + // Handle "say" type messages + if (type === "say" && say) { + this.handleSayMessage(ts, say, text, isPartial) + } + // Handle "ask" type messages + else if (type === "ask" && ask) { + this.handleAskMessage(ts, ask, text, isPartial) + } + } + } + } + + /** + * Handle messageUpdated - individual streaming updates for a single message + * This is where real-time streaming happens! + */ + private handleMessageUpdated(msg: Record): void { + const clineMessage = msg.clineMessage as Record | undefined + if (!clineMessage) return + + const ts = clineMessage.ts as number | undefined + const isPartial = clineMessage.partial as boolean | undefined + const text = clineMessage.text as string + const type = clineMessage.type as string + const say = clineMessage.say as string | undefined + const ask = clineMessage.ask as string | undefined + + if (!ts) return + + // Handle "say" type messages + if (type === "say" && say) { + this.handleSayMessage(ts, say, text, isPartial) + } + // Handle "ask" type messages + else if (type === "ask" && ask) { + this.handleAskMessage(ts, ask, text, isPartial) + } + } + + /** + * Write streaming output directly to stdout (bypassing quiet mode if needed) + */ + private writeStream(text: string): void { + process.stdout.write(text) + } + + /** + * Stream content with delta computation - only output new characters + */ + private streamContent(ts: number, text: string, header: string): void { + const previous = this.streamedContent.get(ts) + + if (!previous) { + // First time seeing this message - output header and initial text + this.writeStream(`\n${header} `) + this.writeStream(text) + this.streamedContent.set(ts, { text, headerShown: true }) + this.currentlyStreamingTs = ts + } else if (text.length > previous.text.length && text.startsWith(previous.text)) { + // Text has grown - output delta + const delta = text.slice(previous.text.length) + this.writeStream(delta) + this.streamedContent.set(ts, { text, headerShown: true }) + } + } + + /** + * Finish streaming a message (add newline) + */ + private finishStream(ts: number): void { + if (this.currentlyStreamingTs === ts) { + this.writeStream("\n") + this.currentlyStreamingTs = null + } + } + + /** + * Handle "say" type messages + */ + private handleSayMessage(ts: number, say: string, text: string, isPartial: boolean | undefined): void { + const previousDisplay = this.displayedMessages.get(ts) + const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial + + switch (say) { + case "text": + // Skip the initial user prompt echo (first message with no prior messages) + if (this.displayedMessages.size === 0 && !previousDisplay) { + this.displayedMessages.set(ts, { text, partial: !!isPartial }) + break + } + + if (isPartial && text) { + // Stream partial content + this.streamContent(ts, text, "[assistant]") + this.displayedMessages.set(ts, { text, partial: true }) + } else if (!isPartial && text && !alreadyDisplayedComplete) { + // Message complete - ensure all content is output + const streamed = this.streamedContent.get(ts) + if (streamed) { + // We were streaming - output any remaining delta and finish + if (text.length > streamed.text.length && text.startsWith(streamed.text)) { + const delta = text.slice(streamed.text.length) + this.writeStream(delta) + } + this.finishStream(ts) + } else { + // Not streamed yet - output complete message + this.output("\n[assistant]", text) + } + this.displayedMessages.set(ts, { text, partial: false }) + this.streamedContent.set(ts, { text, headerShown: true }) + } + break + + case "thinking": + case "reasoning": + // Stream reasoning content in real-time. + this.log(`Received ${say} message: partial=${isPartial}, textLength=${text?.length ?? 0}`) + if (isPartial && text) { + this.streamContent(ts, text, "[reasoning]") + this.displayedMessages.set(ts, { text, partial: true }) + } else if (!isPartial && text && !alreadyDisplayedComplete) { + // Reasoning complete - finish the stream. + const streamed = this.streamedContent.get(ts) + if (streamed) { + if (text.length > streamed.text.length && text.startsWith(streamed.text)) { + const delta = text.slice(streamed.text.length) + this.writeStream(delta) + } + this.finishStream(ts) + } else { + this.output("\n[reasoning]", text) + } + this.displayedMessages.set(ts, { text, partial: false }) + } + break + + case "command_output": + // Stream command output in real-time. + if (isPartial && text) { + this.streamContent(ts, text, "[command output]") + this.displayedMessages.set(ts, { text, partial: true }) + } else if (!isPartial && text && !alreadyDisplayedComplete) { + // Command output complete - finish the stream. + const streamed = this.streamedContent.get(ts) + if (streamed) { + if (text.length > streamed.text.length && text.startsWith(streamed.text)) { + const delta = text.slice(streamed.text.length) + this.writeStream(delta) + } + this.finishStream(ts) + } else { + this.writeStream("\n[command output] ") + this.writeStream(text) + this.writeStream("\n") + } + this.displayedMessages.set(ts, { text, partial: false }) + } + break + + case "completion_result": + // Only process when message is complete (not partial) + if (!isPartial && !alreadyDisplayedComplete) { + this.output("\n[task complete]", text || "") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + this.emit("taskComplete") + } else if (isPartial) { + // Track partial messages but don't output yet - wait for complete message + this.displayedMessages.set(ts, { text: text || "", partial: true }) + } + break + + case "error": + // Display errors to the user but don't terminate the task + // Errors like command timeouts are informational - the agent should decide what to do next + if (!alreadyDisplayedComplete) { + this.outputError("\n[error]", text || "Unknown error") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + case "tool": + // Tool usage - show when complete + if (text && !alreadyDisplayedComplete) { + this.output("\n[tool]", text) + this.displayedMessages.set(ts, { text, partial: false }) + } + break + + case "api_req_started": + // API request started - log in verbose mode + if (this.options.verbose) { + this.log(`API request started: ts=${ts}`) + } + break + + default: + // Other say types - show in verbose mode + if (this.options.verbose) { + this.log(`Unknown say type: ${say}, text length: ${text?.length ?? 0}, partial: ${isPartial}`) + if (text && !alreadyDisplayedComplete) { + this.output(`\n[${say}]`, text || "") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + } + } + } + + /** + * Handle "ask" type messages - these require user responses + * In interactive mode: prompt user for input + * In non-interactive mode: auto-approve (handled by extension settings) + */ + private handleAskMessage(ts: number, ask: string, text: string, isPartial: boolean | undefined): void { + // Special handling for command_output - stream it in real-time + // This needs to happen before the isPartial skip + if (ask === "command_output") { + this.handleCommandOutputAsk(ts, text, isPartial) + return + } + + // Skip partial messages - wait for the complete ask + if (isPartial) { + return + } + + // Check if we already handled this ask + if (this.pendingAsks.has(ts)) { + return + } + + // In non-interactive mode, the extension's auto-approval settings handle everything + // We just need to display the action being taken + if (this.options.nonInteractive) { + this.handleAskMessageNonInteractive(ts, ask, text) + return + } + + // Interactive mode - prompt user for input + this.handleAskMessageInteractive(ts, ask, text) + } + + /** + * Handle ask messages in non-interactive mode + * For followup questions: show prompt with 10s timeout, auto-select first option if no input + * For everything else: auto-approval handles responses + */ + private handleAskMessageNonInteractive(ts: number, ask: string, text: string): void { + const previousDisplay = this.displayedMessages.get(ts) + const alreadyDisplayed = !!previousDisplay + + switch (ask) { + case "followup": + if (!alreadyDisplayed) { + // In non-interactive mode, still prompt the user but with a 10s timeout + // that auto-selects the first option if no input is received + this.pendingAsks.add(ts) + this.handleFollowupQuestionWithTimeout(ts, text) + this.displayedMessages.set(ts, { text, partial: false }) + } + break + + case "command": + if (!alreadyDisplayed) { + this.output("\n[command]", text || "") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + // Note: command_output is handled separately in handleCommandOutputAsk + + case "tool": + if (!alreadyDisplayed && text) { + try { + const toolInfo = JSON.parse(text) + const toolName = toolInfo.tool || "unknown" + this.output(`\n[tool] ${toolName}`) + // Display all tool parameters (excluding 'tool' which is the name) + for (const [key, value] of Object.entries(toolInfo)) { + if (key === "tool") continue + // Format the value - truncate long strings + let displayValue: string + if (typeof value === "string") { + displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value + } else if (typeof value === "object" && value !== null) { + const json = JSON.stringify(value) + displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json + } else { + displayValue = String(value) + } + this.output(` ${key}: ${displayValue}`) + } + } catch { + this.output("\n[tool]", text) + } + this.displayedMessages.set(ts, { text, partial: false }) + } + break + + case "browser_action_launch": + if (!alreadyDisplayed) { + this.output("\n[browser action]", text || "") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + case "use_mcp_server": + if (!alreadyDisplayed) { + try { + const mcpInfo = JSON.parse(text) + this.output(`\n[mcp] ${mcpInfo.server_name || "unknown"}`) + } catch { + this.output("\n[mcp]", text || "") + } + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + case "api_req_failed": + if (!alreadyDisplayed) { + this.output("\n[retrying api Request]") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + case "resume_task": + case "resume_completed_task": + if (!alreadyDisplayed) { + this.output("\n[continuing task]") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + case "completion_result": + // Task completion - no action needed + break + + default: + if (!alreadyDisplayed && text) { + this.output(`\n[${ask}]`, text) + this.displayedMessages.set(ts, { text, partial: false }) + } + } + } + + /** + * Handle ask messages in interactive mode - prompt user for input + */ + private handleAskMessageInteractive(ts: number, ask: string, text: string): void { + // Mark this ask as pending so we don't handle it again + this.pendingAsks.add(ts) + + switch (ask) { + case "followup": + this.handleFollowupQuestion(ts, text) + break + + case "command": + this.handleCommandApproval(ts, text) + break + + // Note: command_output is handled separately in handleCommandOutputAsk + + case "tool": + this.handleToolApproval(ts, text) + break + + case "browser_action_launch": + this.handleBrowserApproval(ts, text) + break + + case "use_mcp_server": + this.handleMcpApproval(ts, text) + break + + case "api_req_failed": + this.handleApiFailedRetry(ts, text) + break + + case "resume_task": + case "resume_completed_task": + this.handleResumeTask(ts, ask, text) + break + + case "completion_result": + // Task completion - handled by say message, no response needed + this.pendingAsks.delete(ts) + break + + default: + // Unknown ask type - try to handle as yes/no + this.handleGenericApproval(ts, ask, text) + } + } + + /** + * Handle followup questions - prompt for text input with suggestions + */ + private async handleFollowupQuestion(ts: number, text: string): Promise { + let question = text + // Suggestions are objects with { answer: string, mode?: string } + let suggestions: Array<{ answer: string; mode?: string | null }> = [] + + // Parse the followup question JSON + // Format: { question: "...", suggest: [{ answer: "text", mode: "code" }, ...] } + try { + const data = JSON.parse(text) + question = data.question || text + suggestions = Array.isArray(data.suggest) ? data.suggest : [] + } catch { + // Use raw text if not JSON + } + + this.output("\n[question]", question) + + // Show numbered suggestions + if (suggestions.length > 0) { + this.output("\nSuggested answers:") + suggestions.forEach((suggestion, index) => { + const suggestionText = suggestion.answer || String(suggestion) + const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : "" + this.output(` ${index + 1}. ${suggestionText}${modeHint}`) + }) + this.output("") + } + + try { + const answer = await this.promptForInput( + suggestions.length > 0 + ? "Enter number (1-" + suggestions.length + ") or type your answer: " + : "Your answer: ", + ) + + let responseText = answer.trim() + + // Check if user entered a number corresponding to a suggestion + const num = parseInt(responseText, 10) + if (!isNaN(num) && num >= 1 && num <= suggestions.length) { + const selectedSuggestion = suggestions[num - 1] + if (selectedSuggestion) { + responseText = selectedSuggestion.answer || String(selectedSuggestion) + this.output(`Selected: ${responseText}`) + } + } + + this.sendFollowupResponse(responseText) + // Don't delete from pendingAsks - keep it to prevent re-processing + // if the extension sends another state update before processing our response + } catch { + // If prompt fails (e.g., stdin closed), use first suggestion answer or empty + const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null + const fallback = firstSuggestion?.answer ?? "" + this.output(`[Using default: ${fallback || "(empty)"}]`) + this.sendFollowupResponse(fallback) + } + // Note: We intentionally don't delete from pendingAsks here. + // The ts stays in the set to prevent duplicate handling if the extension + // sends another state update before it processes our response. + // The set is cleared when the task completes or the host is disposed. + } + + /** + * Handle followup questions with a timeout (for non-interactive mode) + * Shows the prompt but auto-selects the first option after 10 seconds + * if the user doesn't type anything. Cancels the timeout on any keypress. + */ + private async handleFollowupQuestionWithTimeout(ts: number, text: string): Promise { + let question = text + // Suggestions are objects with { answer: string, mode?: string } + let suggestions: Array<{ answer: string; mode?: string | null }> = [] + + // Parse the followup question JSON + try { + const data = JSON.parse(text) + question = data.question || text + suggestions = Array.isArray(data.suggest) ? data.suggest : [] + } catch { + // Use raw text if not JSON + } + + this.output("\n[question]", question) + + // Show numbered suggestions + if (suggestions.length > 0) { + this.output("\nSuggested answers:") + suggestions.forEach((suggestion, index) => { + const suggestionText = suggestion.answer || String(suggestion) + const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : "" + this.output(` ${index + 1}. ${suggestionText}${modeHint}`) + }) + this.output("") + } + + // Default to first suggestion or empty string + const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null + const defaultAnswer = firstSuggestion?.answer ?? "" + + try { + const answer = await this.promptForInputWithTimeout( + suggestions.length > 0 + ? `Enter number (1-${suggestions.length}) or type your answer (auto-select in 10s): ` + : "Your answer (auto-select in 10s): ", + 10000, // 10 second timeout + defaultAnswer, + ) + + let responseText = answer.trim() + + // Check if user entered a number corresponding to a suggestion + const num = parseInt(responseText, 10) + if (!isNaN(num) && num >= 1 && num <= suggestions.length) { + const selectedSuggestion = suggestions[num - 1] + if (selectedSuggestion) { + responseText = selectedSuggestion.answer || String(selectedSuggestion) + this.output(`Selected: ${responseText}`) + } + } + + this.sendFollowupResponse(responseText) + } catch { + // If prompt fails, use default + this.output(`[Using default: ${defaultAnswer || "(empty)"}]`) + this.sendFollowupResponse(defaultAnswer) + } + } + + /** + * Prompt user for text input with a timeout + * Returns defaultValue if timeout expires before any input + * Cancels timeout as soon as any character is typed + */ + private promptForInputWithTimeout(prompt: string, timeoutMs: number, defaultValue: string): Promise { + return new Promise((resolve) => { + // Temporarily restore console for interactive prompts + const wasQuiet = this.options.quiet + if (wasQuiet) { + this.restoreConsole() + } + + // Put stdin in raw mode to detect individual keypresses + const wasRaw = process.stdin.isRaw + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + + let inputBuffer = "" + let timeoutCancelled = false + let resolved = false + + // Set up the timeout + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true + cleanup() + this.output(`\n[Timeout - using default: ${defaultValue || "(empty)"}]`) + resolve(defaultValue) + } + }, timeoutMs) + + // Show the prompt + process.stdout.write(prompt) + + // Cleanup function + const cleanup = () => { + clearTimeout(timeout) + process.stdin.removeListener("data", onData) + if (process.stdin.isTTY && wasRaw !== undefined) { + process.stdin.setRawMode(wasRaw) + } + process.stdin.pause() + if (wasQuiet) { + this.setupQuietMode() + } + } + + // Handle keypress data + const onData = (data: Buffer) => { + const char = data.toString() + + // Check for Ctrl+C + if (char === "\x03") { + cleanup() + resolved = true + this.output("\n[cancelled]") + resolve(defaultValue) + return + } + + // Cancel timeout on first character + if (!timeoutCancelled) { + timeoutCancelled = true + clearTimeout(timeout) + } + + // Handle Enter key + if (char === "\r" || char === "\n") { + if (!resolved) { + resolved = true + cleanup() + process.stdout.write("\n") + resolve(inputBuffer) + } + return + } + + // Handle Backspace + if (char === "\x7f" || char === "\b") { + if (inputBuffer.length > 0) { + inputBuffer = inputBuffer.slice(0, -1) + // Erase character on screen: move back, write space, move back + process.stdout.write("\b \b") + } + return + } + + // Regular character - add to buffer and echo + inputBuffer += char + process.stdout.write(char) + } + + process.stdin.on("data", onData) + }) + } + + /** + * Handle command execution approval + */ + private async handleCommandApproval(ts: number, text: string): Promise { + this.output("\n[command request]") + this.output(` Command: ${text || "(no command specified)"}`) + + try { + const approved = await this.promptForYesNo("Execute this command? (y/n): ") + this.sendApprovalResponse(approved) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle tool execution approval + */ + private async handleToolApproval(ts: number, text: string): Promise { + let toolName = "unknown" + let toolInfo: Record = {} + + try { + toolInfo = JSON.parse(text) as Record + toolName = (toolInfo.tool as string) || "unknown" + } catch { + // Use raw text if not JSON + } + + this.output(`\n[Tool Request] ${toolName}`) + // Display all tool parameters (excluding 'tool' which is the name) + for (const [key, value] of Object.entries(toolInfo)) { + if (key === "tool") continue + // Format the value - truncate long strings + let displayValue: string + if (typeof value === "string") { + displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value + } else if (typeof value === "object" && value !== null) { + const json = JSON.stringify(value) + displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json + } else { + displayValue = String(value) + } + this.output(` ${key}: ${displayValue}`) + } + + try { + const approved = await this.promptForYesNo("Approve this action? (y/n): ") + this.sendApprovalResponse(approved) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle browser action approval + */ + private async handleBrowserApproval(ts: number, text: string): Promise { + this.output("\n[browser action request]") + if (text) this.output(` Action: ${text}`) + + try { + const approved = await this.promptForYesNo("Allow browser action? (y/n): ") + this.sendApprovalResponse(approved) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle MCP server access approval + */ + private async handleMcpApproval(ts: number, text: string): Promise { + let serverName = "unknown" + let toolName = "" + let resourceUri = "" + + try { + const mcpInfo = JSON.parse(text) + serverName = mcpInfo.server_name || "unknown" + if (mcpInfo.type === "use_mcp_tool") { + toolName = mcpInfo.tool_name || "" + } else if (mcpInfo.type === "access_mcp_resource") { + resourceUri = mcpInfo.uri || "" + } + } catch { + // Use raw text if not JSON + } + + this.output("\n[mcp request]") + this.output(` Server: ${serverName}`) + if (toolName) this.output(` Tool: ${toolName}`) + if (resourceUri) this.output(` Resource: ${resourceUri}`) + + try { + const approved = await this.promptForYesNo("Allow MCP access? (y/n): ") + this.sendApprovalResponse(approved) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle API request failed - retry prompt + */ + private async handleApiFailedRetry(ts: number, text: string): Promise { + this.output("\n[api request failed]") + this.output(` Error: ${text || "Unknown error"}`) + + try { + const retry = await this.promptForYesNo("Retry the request? (y/n): ") + this.sendApprovalResponse(retry) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle task resume prompt + */ + private async handleResumeTask(ts: number, ask: string, text: string): Promise { + const isCompleted = ask === "resume_completed_task" + this.output(`\n[Resume ${isCompleted ? "Completed " : ""}Task]`) + if (text) this.output(` ${text}`) + + try { + const resume = await this.promptForYesNo("Continue with this task? (y/n): ") + this.sendApprovalResponse(resume) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle generic approval prompts for unknown ask types + */ + private async handleGenericApproval(ts: number, ask: string, text: string): Promise { + this.output(`\n[${ask}]`) + if (text) this.output(` ${text}`) + + try { + const approved = await this.promptForYesNo("Approve? (y/n): ") + this.sendApprovalResponse(approved) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle command_output ask messages - stream the output in real-time + * This is called for both partial (streaming) and complete messages + */ + private handleCommandOutputAsk(ts: number, text: string, isPartial: boolean | undefined): void { + const previousDisplay = this.displayedMessages.get(ts) + const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial + + // Stream partial content + if (isPartial && text) { + this.streamContent(ts, text, "[command output]") + this.displayedMessages.set(ts, { text, partial: true }) + } else if (!isPartial) { + // Message complete - output any remaining content and send approval + if (text && !alreadyDisplayedComplete) { + const streamed = this.streamedContent.get(ts) + if (streamed) { + // We were streaming - output any remaining delta and finish. + if (text.length > streamed.text.length && text.startsWith(streamed.text)) { + const delta = text.slice(streamed.text.length) + this.writeStream(delta) + } + this.finishStream(ts) + } else { + this.writeStream("\n[command output] ") + this.writeStream(text) + this.writeStream("\n") + } + this.displayedMessages.set(ts, { text, partial: false }) + this.streamedContent.set(ts, { text, headerShown: true }) + } + + // Send approval response (only once per ts). + if (!this.pendingAsks.has(ts)) { + this.pendingAsks.add(ts) + this.sendApprovalResponse(true) + } + } + } + + /** + * Prompt user for text input via readline + */ + private promptForInput(prompt: string): Promise { + return new Promise((resolve, reject) => { + // Temporarily restore console for interactive prompts + const wasQuiet = this.options.quiet + if (wasQuiet) { + this.restoreConsole() + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + rl.question(prompt, (answer) => { + rl.close() + + // Restore quiet mode if it was enabled + if (wasQuiet) { + this.setupQuietMode() + } + + resolve(answer) + }) + + // Handle stdin close (e.g., piped input ended) + rl.on("close", () => { + if (wasQuiet) { + this.setupQuietMode() + } + }) + + // Handle errors + rl.on("error", (err) => { + rl.close() + if (wasQuiet) { + this.setupQuietMode() + } + reject(err) + }) + }) + } + + /** + * Prompt user for yes/no input + */ + private async promptForYesNo(prompt: string): Promise { + const answer = await this.promptForInput(prompt) + const normalized = answer.trim().toLowerCase() + // Accept y, yes, Y, Yes, YES, etc. + return normalized === "y" || normalized === "yes" + } + + /** + * Send a followup response (text answer) to the extension + */ + private sendFollowupResponse(text: string): void { + this.sendToExtension({ + type: "askResponse", + askResponse: "messageResponse", + text, + }) + } + + /** + * Send an approval response (yes/no) to the extension + */ + private sendApprovalResponse(approved: boolean): void { + this.sendToExtension({ + type: "askResponse", + askResponse: approved ? "yesButtonClicked" : "noButtonClicked", + }) + } + + /** + * Handle action messages + */ + private handleActionMessage(msg: Record): void { + const action = msg.action as string + + if (this.options.verbose) { + this.log("Action:", action) + } + } + + /** + * Handle invoke messages + */ + private handleInvokeMessage(msg: Record): void { + const invoke = msg.invoke as string + + if (this.options.verbose) { + this.log("Invoke:", invoke) + } + } + + /** + * Wait for the task to complete + */ + private waitForCompletion(): Promise { + return new Promise((resolve, reject) => { + const completeHandler = () => { + cleanup() + resolve() + } + + const errorHandler = (error: string) => { + cleanup() + reject(new Error(error)) + } + + const cleanup = () => { + this.off("taskComplete", completeHandler) + this.off("taskError", errorHandler) + } + + this.once("taskComplete", completeHandler) + this.once("taskError", errorHandler) + + // Set a timeout (10 minutes by default) + const timeout = setTimeout( + () => { + cleanup() + reject(new Error("Task timed out")) + }, + 10 * 60 * 1000, + ) + + // Clear timeout on completion + this.once("taskComplete", () => clearTimeout(timeout)) + this.once("taskError", () => clearTimeout(timeout)) + }) + } + + /** + * Clean up resources + */ + async dispose(): Promise { + this.log("Disposing extension host...") + + // Clear pending asks + this.pendingAsks.clear() + + // Close readline interface if open + if (this.rl) { + this.rl.close() + this.rl = null + } + + // Remove message listener + if (this.messageListener) { + this.off("extensionWebviewMessage", this.messageListener) + this.messageListener = null + } + + // Deactivate extension if it has a deactivate function + if (this.extensionModule?.deactivate) { + try { + await this.extensionModule.deactivate() + } catch (error) { + this.log("Error deactivating extension:", error) + } + } + + // Clear references + this.vscode = null + this.extensionModule = null + this.extensionAPI = null + this.webviewProviders.clear() + + // Clear globals + delete (global as Record).vscode + delete (global as Record).__extensionHost + + // Restore console if it was suppressed + this.restoreConsole() + + this.log("Extension host disposed") + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts new file mode 100644 index 0000000000..15a2786ef4 --- /dev/null +++ b/apps/cli/src/index.ts @@ -0,0 +1,163 @@ +/** + * @roo-code/cli - Command Line Interface for Roo Code + */ + +import { Command } from "commander" +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +import { + type ProviderName, + type ReasoningEffortExtended, + isProviderName, + reasoningEffortsExtended, +} from "@roo-code/types" +import { setLogger } from "@roo-code/vscode-shim" + +import { ExtensionHost } from "./extension-host.js" +import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils.js" + +const DEFAULTS = { + mode: "code", + reasoningEffort: "medium" as const, + model: "anthropic/claude-sonnet-4.5", +} + +const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"] + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const program = new Command() + +program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version("0.1.0") + +program + .argument("", "The prompt/task to execute") + .option("-w, --workspace ", "Workspace path to operate in", process.cwd()) + .option("-e, --extension ", "Path to the extension bundle directory") + .option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false) + .option("-d, --debug", "Enable debug output (includes detailed debug information)", false) + .option("-x, --exit-on-complete", "Exit the process when the task completes (useful for testing)", false) + .option("-y, --yes", "Auto-approve all prompts (non-interactive mode)", false) + .option("-k, --api-key ", "API key for the LLM provider (defaults to ANTHROPIC_API_KEY env var)") + .option("-p, --provider ", "API provider (anthropic, openai, openrouter, etc.)", "openrouter") + .option("-m, --model ", "Model to use", DEFAULTS.model) + .option("-M, --mode ", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULTS.mode) + .option( + "-r, --reasoning-effort ", + "Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)", + DEFAULTS.reasoningEffort, + ) + .action( + async ( + prompt: string, + options: { + workspace: string + extension?: string + verbose: boolean + debug: boolean + exitOnComplete: boolean + yes: boolean + apiKey?: string + provider: ProviderName + model?: string + mode?: string + reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled" + }, + ) => { + // Default is quiet mode - suppress VSCode shim logs unless verbose + // or debug is specified. + if (!options.verbose && !options.debug) { + setLogger({ + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }) + } + + const extensionPath = options.extension || getDefaultExtensionPath(__dirname) + const apiKey = options.apiKey || getApiKeyFromEnv(options.provider) + const workspacePath = path.resolve(options.workspace) + + if (!apiKey) { + console.error( + `[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`, + ) + console.error(`[CLI] For ${options.provider}, set ${getEnvVarName(options.provider)}`) + process.exit(1) + } + + if (!fs.existsSync(workspacePath)) { + console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`) + process.exit(1) + } + + if (!isProviderName(options.provider)) { + console.error(`[CLI] Error: Invalid provider: ${options.provider}`) + process.exit(1) + } + + if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) { + console.error( + `[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`, + ) + process.exit(1) + } + + console.log(`[CLI] Mode: ${options.mode || "default"}`) + console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`) + console.log(`[CLI] Provider: ${options.provider}`) + console.log(`[CLI] Model: ${options.model || "default"}`) + console.log(`[CLI] Workspace: ${workspacePath}`) + + const host = new ExtensionHost({ + mode: options.mode || DEFAULTS.mode, + reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort, + apiProvider: options.provider, + apiKey, + model: options.model || DEFAULTS.model, + workspacePath, + extensionPath: path.resolve(extensionPath), + verbose: options.debug, + quiet: !options.verbose && !options.debug, + nonInteractive: options.yes, + }) + + // Handle SIGINT (Ctrl+C) + process.on("SIGINT", async () => { + console.log("\n[CLI] Received SIGINT, shutting down...") + await host.dispose() + process.exit(130) + }) + + // Handle SIGTERM + process.on("SIGTERM", async () => { + console.log("\n[CLI] Received SIGTERM, shutting down...") + await host.dispose() + process.exit(143) + }) + + try { + await host.activate() + await host.runTask(prompt) + await host.dispose() + + if (options.exitOnComplete) { + process.exit(0) + } + } catch (error) { + console.error("[CLI] Error:", error instanceof Error ? error.message : String(error)) + + if (options.debug && error instanceof Error) { + console.error(error.stack) + } + + await host.dispose() + process.exit(1) + } + }, + ) + +program.parse() diff --git a/apps/cli/src/utils.ts b/apps/cli/src/utils.ts new file mode 100644 index 0000000000..5ea12e33b6 --- /dev/null +++ b/apps/cli/src/utils.ts @@ -0,0 +1,62 @@ +/** + * Utility functions for the Roo Code CLI + */ + +import path from "path" +import fs from "fs" + +/** + * Get the environment variable name for a provider's API key + */ +export function getEnvVarName(provider: string): string { + const envVarMap: Record = { + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + openrouter: "OPENROUTER_API_KEY", + google: "GOOGLE_API_KEY", + gemini: "GOOGLE_API_KEY", + bedrock: "AWS_ACCESS_KEY_ID", + ollama: "OLLAMA_API_KEY", + mistral: "MISTRAL_API_KEY", + deepseek: "DEEPSEEK_API_KEY", + } + return envVarMap[provider.toLowerCase()] || `${provider.toUpperCase()}_API_KEY` +} + +/** + * Get API key from environment variable based on provider + */ +export function getApiKeyFromEnv(provider: string): string | undefined { + const envVar = getEnvVarName(provider) + return process.env[envVar] +} + +/** + * Get the default path to the extension bundle. + * This assumes the CLI is installed alongside the built extension. + * + * @param dirname - The __dirname equivalent for the calling module + */ +export function getDefaultExtensionPath(dirname: string): string { + // Check for environment variable first (set by install script) + if (process.env.ROO_EXTENSION_PATH) { + const envPath = process.env.ROO_EXTENSION_PATH + if (fs.existsSync(path.join(envPath, "extension.js"))) { + return envPath + } + } + + // __dirname is apps/cli/dist when bundled + // The extension is at src/dist (relative to monorepo root) + // So from apps/cli/dist, we need to go ../../../src/dist + const monorepoPath = path.resolve(dirname, "../../../src/dist") + + // Try monorepo path first (for development) + if (fs.existsSync(path.join(monorepoPath, "extension.js"))) { + return monorepoPath + } + + // Fallback: when installed via curl script, extension is at ../extension + const packagePath = path.resolve(dirname, "../extension") + return packagePath +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000000..9893fe2966 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@roo-code/config-typescript/base.json", + "compilerOptions": { + "types": ["vitest/globals"], + "outDir": "dist" + }, + "include": ["src", "*.config.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts new file mode 100644 index 0000000000..f692148c3d --- /dev/null +++ b/apps/cli/tsup.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + target: "node20", + platform: "node", + banner: { + js: "#!/usr/bin/env node", + }, + // Bundle workspace packages that export TypeScript + noExternal: ["@roo-code/types", "@roo-code/vscode-shim"], + external: [ + // Keep native modules external + "@anthropic-ai/sdk", + "@anthropic-ai/bedrock-sdk", + "@anthropic-ai/vertex-sdk", + // Keep @vscode/ripgrep external - we bundle the binary separately + "@vscode/ripgrep", + ], +}) diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts new file mode 100644 index 0000000000..a558a62e83 --- /dev/null +++ b/apps/cli/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globals: true, + environment: "node", + watch: false, + testTimeout: 120_000, // 2m for integration tests. + include: ["src/**/*.test.ts"], + }, +}) diff --git a/apps/vscode-e2e/src/suite/extension.test.ts b/apps/vscode-e2e/src/suite/extension.test.ts index 5d59e003ef..c5340a882d 100644 --- a/apps/vscode-e2e/src/suite/extension.test.ts +++ b/apps/vscode-e2e/src/suite/extension.test.ts @@ -19,10 +19,6 @@ suite("Roo Code Extension", function () { "openInNewTab", "settingsButtonClicked", "historyButtonClicked", - "showHumanRelayDialog", - "registerHumanRelayCallback", - "unregisterHumanRelayCallback", - "handleHumanRelayResponse", "newTask", "setCustomStoragePath", "focusInput", diff --git a/apps/web-evals/src/actions/runs.ts b/apps/web-evals/src/actions/runs.ts index 9d213547ce..f0c1578aed 100644 --- a/apps/web-evals/src/actions/runs.ts +++ b/apps/web-evals/src/actions/runs.ts @@ -28,10 +28,18 @@ const EVALS_STORAGE_PATH = "/tmp/evals/runs" const EVALS_REPO_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../../evals") -export async function createRun({ suite, exercises = [], timeout, iterations = 1, ...values }: CreateRun) { +export async function createRun({ + suite, + exercises = [], + timeout, + iterations = 1, + executionMethod = "vscode", + ...values +}: CreateRun) { const run = await _createRun({ ...values, timeout, + executionMethod, 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 be015ac8ca..cea15c6ddd 100644 --- a/apps/web-evals/src/app/runs/new/new-run.tsx +++ b/apps/web-evals/src/app/runs/new/new-run.tsx @@ -1,21 +1,32 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useRouter } from "next/navigation" import { z } from "zod" import { useQuery } from "@tanstack/react-query" import { useForm, FormProvider } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { toast } from "sonner" -import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal, Info, Plus, Minus } from "lucide-react" +import { + X, + Rocket, + Check, + ChevronsUpDown, + SlidersHorizontal, + Info, + Plus, + Minus, + Terminal, + MonitorPlay, +} from "lucide-react" import { - globalSettingsSchema, - providerSettingsSchema, - EVALS_SETTINGS, - getModelId, type ProviderSettings, type GlobalSettings, + globalSettingsSchema, + providerSettingsSchema, + getModelId, + EVALS_SETTINGS, } from "@roo-code/types" import { createRun } from "@/actions/runs" @@ -23,6 +34,7 @@ import { getExercises } from "@/actions/exercises" import { type CreateRun, + type ExecutionMethod, createRunSchema, CONCURRENCY_MIN, CONCURRENCY_MAX, @@ -36,6 +48,9 @@ import { } from "@/lib/schemas" import { cn } from "@/lib/utils" +import { loadRooLastModelSelection, saveRooLastModelSelection } from "@/lib/roo-last-model-selection" +import { normalizeCreateRunForSubmit } from "@/lib/normalize-create-run" + import { useOpenRouterModels } from "@/hooks/use-open-router-models" import { useRooCodeCloudModels } from "@/hooks/use-roo-code-cloud-models" @@ -77,14 +92,12 @@ type ImportedSettings = { currentApiConfigName: string } -// Type for a model selection entry type ModelSelection = { id: string model: string popoverOpen: boolean } -// Type for a config selection entry (for import mode) type ConfigSelection = { id: string configName: string @@ -93,18 +106,19 @@ type ConfigSelection = { export function NewRun() { const router = useRouter() + const modelSelectionsByProviderRef = useRef>({}) + const modelValueByProviderRef = useRef>({}) const [provider, setModelSource] = useState<"roo" | "openrouter" | "other">("other") + const [executionMethod, setExecutionMethod] = useState("vscode") const [useNativeToolProtocol, setUseNativeToolProtocol] = useState(true) const [commandExecutionTimeout, setCommandExecutionTimeout] = useState(20) const [terminalShellIntegrationTimeout, setTerminalShellIntegrationTimeout] = useState(30) // seconds - // State for multiple model selections const [modelSelections, setModelSelections] = useState([ { id: crypto.randomUUID(), model: "", popoverOpen: false }, ]) - // State for imported settings with multiple config selections const [importedSettings, setImportedSettings] = useState(null) const [configSelections, setConfigSelections] = useState([ { id: crypto.randomUUID(), configName: "", popoverOpen: false }, @@ -119,7 +133,6 @@ export function NewRun() { const exercises = useQuery({ queryKey: ["getExercises"], queryFn: () => getExercises() }) - // State for selected exercises (needed for language toggle buttons) const [selectedExercises, setSelectedExercises] = useState([]) const form = useForm({ @@ -134,50 +147,91 @@ export function NewRun() { timeout: TIMEOUT_DEFAULT, iterations: ITERATIONS_DEFAULT, jobToken: "", + executionMethod: "vscode", }, }) const { + register, setValue, clearErrors, watch, + getValues, formState: { isSubmitting }, } = form const [suite, settings] = watch(["suite", "settings", "concurrency"]) + const selectedModelIds = useMemo( + () => modelSelections.map((s) => s.model).filter((m) => m.length > 0), + [modelSelections], + ) + + const applyModelIds = useCallback( + (modelIds: string[]) => { + const unique = Array.from(new Set(modelIds.map((m) => m.trim()).filter((m) => m.length > 0))) + + if (unique.length === 0) { + setModelSelections([{ id: crypto.randomUUID(), model: "", popoverOpen: false }]) + setValue("model", "") + return + } + + setModelSelections(unique.map((model) => ({ id: crypto.randomUUID(), model, popoverOpen: false }))) + setValue("model", unique[0] ?? "") + }, + [setValue], + ) + + // Ensure the `exercises` field is registered so RHF always includes it in submit values. + useEffect(() => { + register("exercises") + }, [register]) + // Load settings from localStorage on mount useEffect(() => { const savedConcurrency = localStorage.getItem("evals-concurrency") + if (savedConcurrency) { const parsed = parseInt(savedConcurrency, 10) + if (!isNaN(parsed) && parsed >= CONCURRENCY_MIN && parsed <= CONCURRENCY_MAX) { setValue("concurrency", parsed) } } + const savedTimeout = localStorage.getItem("evals-timeout") + if (savedTimeout) { const parsed = parseInt(savedTimeout, 10) + if (!isNaN(parsed) && parsed >= TIMEOUT_MIN && parsed <= TIMEOUT_MAX) { setValue("timeout", parsed) } } + const savedCommandTimeout = localStorage.getItem("evals-command-execution-timeout") + if (savedCommandTimeout) { const parsed = parseInt(savedCommandTimeout, 10) + if (!isNaN(parsed) && parsed >= 20 && parsed <= 60) { setCommandExecutionTimeout(parsed) } } + const savedShellTimeout = localStorage.getItem("evals-shell-integration-timeout") + if (savedShellTimeout) { const parsed = parseInt(savedShellTimeout, 10) + if (!isNaN(parsed) && parsed >= 30 && parsed <= 60) { setTerminalShellIntegrationTimeout(parsed) } } - // Load saved exercises selection + const savedSuite = localStorage.getItem("evals-suite") + if (savedSuite === "partial") { setValue("suite", "partial") const savedExercises = localStorage.getItem("evals-exercises") @@ -189,48 +243,102 @@ export function NewRun() { setValue("exercises", parsed) } } catch { - // Invalid JSON, ignore + // Invalid JSON, ignore. } } } }, [setValue]) + // Track previous provider to detect switches + const [prevProvider, setPrevProvider] = useState(provider) + + // Preserve selections per provider; avoids cross-contamination while keeping UX stable. + useEffect(() => { + if (provider === prevProvider) return + + modelSelectionsByProviderRef.current[prevProvider] = modelSelections + modelValueByProviderRef.current[prevProvider] = getValues("model") + + const nextModelSelections = + modelSelectionsByProviderRef.current[provider] ?? + ([{ id: crypto.randomUUID(), model: "", popoverOpen: false }] satisfies ModelSelection[]) + + setModelSelections(nextModelSelections) + + const nextModelValue = + modelValueByProviderRef.current[provider] ?? + nextModelSelections.find((s) => s.model.trim().length > 0)?.model ?? + (provider === "other" && importedSettings && configSelections[0]?.configName + ? (getModelId(importedSettings.apiConfigs[configSelections[0].configName] ?? {}) ?? "") + : "") + + setValue("model", nextModelValue) + setPrevProvider(provider) + }, [provider, prevProvider, modelSelections, setValue, getValues, importedSettings, configSelections]) + + // When switching to Roo provider, restore last-used selection if current selection is empty + useEffect(() => { + if (provider !== "roo") return + if (selectedModelIds.length > 0) return + + const last = loadRooLastModelSelection() + if (last.length > 0) { + applyModelIds(last) + } + }, [applyModelIds, provider, selectedModelIds.length]) + + // Persist last-used Roo provider model selection + useEffect(() => { + if (provider !== "roo") return + saveRooLastModelSelection(selectedModelIds) + }, [provider, selectedModelIds]) + // Extract unique languages from exercises const languages = useMemo(() => { - if (!exercises.data) return [] + if (!exercises.data) { + return [] + } + const langs = new Set() + for (const path of exercises.data) { const lang = path.split("/")[0] - if (lang) langs.add(lang) + + if (lang) { + langs.add(lang) + } } + return Array.from(langs).sort() }, [exercises.data]) - // Get exercises for a specific language const getExercisesForLanguage = useCallback( (lang: string) => { - if (!exercises.data) return [] + if (!exercises.data) { + return [] + } + return exercises.data.filter((path) => path.startsWith(`${lang}/`)) }, [exercises.data], ) - // Toggle all exercises for a language const toggleLanguage = useCallback( (lang: string) => { const langExercises = getExercisesForLanguage(lang) const allSelected = langExercises.every((ex) => selectedExercises.includes(ex)) let newSelected: string[] + if (allSelected) { - // Remove all exercises for this language newSelected = selectedExercises.filter((ex) => !ex.startsWith(`${lang}/`)) } else { - // Add all exercises for this language (avoiding duplicates) const existing = new Set(selectedExercises) + for (const ex of langExercises) { existing.add(ex) } + newSelected = Array.from(existing) } @@ -241,7 +349,6 @@ export function NewRun() { [getExercisesForLanguage, selectedExercises, setValue], ) - // Check if all exercises for a language are selected const isLanguageSelected = useCallback( (lang: string) => { const langExercises = getExercisesForLanguage(lang) @@ -250,7 +357,6 @@ export function NewRun() { [getExercisesForLanguage, selectedExercises], ) - // Check if some (but not all) exercises for a language are selected const isLanguagePartiallySelected = useCallback( (lang: string) => { const langExercises = getExercisesForLanguage(lang) @@ -260,46 +366,40 @@ export function NewRun() { [getExercisesForLanguage, selectedExercises], ) - // Add a new model selection const addModelSelection = useCallback(() => { setModelSelections((prev) => [...prev, { id: crypto.randomUUID(), model: "", popoverOpen: false }]) }, []) - // Remove a model selection const removeModelSelection = useCallback((id: string) => { setModelSelections((prev) => prev.filter((s) => s.id !== id)) }, []) - // Update a model selection const updateModelSelection = useCallback( (id: string, model: string) => { setModelSelections((prev) => prev.map((s) => (s.id === id ? { ...s, model, popoverOpen: false } : s))) - // Also set the form model field for validation (use first non-empty model) + // Also set the form model field for validation (use first non-empty model). setValue("model", model) }, [setValue], ) - // Toggle popover for a model selection const toggleModelPopover = useCallback((id: string, open: boolean) => { setModelSelections((prev) => prev.map((s) => (s.id === id ? { ...s, popoverOpen: open } : s))) }, []) - // Add a new config selection const addConfigSelection = useCallback(() => { setConfigSelections((prev) => [...prev, { id: crypto.randomUUID(), configName: "", popoverOpen: false }]) }, []) - // Remove a config selection const removeConfigSelection = useCallback((id: string) => { setConfigSelections((prev) => prev.filter((s) => s.id !== id)) }, []) - // Update a config selection const updateConfigSelection = useCallback( (id: string, configName: string) => { setConfigSelections((prev) => prev.map((s) => (s.id === id ? { ...s, configName, popoverOpen: false } : s))) - // Also update the form settings for the first config (for validation) + + // Also update the form settings for the first config (for validation). if (importedSettings) { const providerSettings = importedSettings.apiConfigs[configName] ?? {} setValue("model", getModelId(providerSettings) ?? "") @@ -309,7 +409,6 @@ export function NewRun() { [importedSettings, setValue], ) - // Toggle popover for a config selection const toggleConfigPopover = useCallback((id: string, open: boolean) => { setConfigSelections((prev) => prev.map((s) => (s.id === id ? { ...s, popoverOpen: open } : s))) }, []) @@ -317,24 +416,23 @@ export function NewRun() { const onSubmit = useCallback( async (values: CreateRun) => { try { + const baseValues = normalizeCreateRunForSubmit(values, selectedExercises, suite) + // Validate jobToken for Roo Code Cloud provider - if (provider === "roo" && !values.jobToken?.trim()) { + if (provider === "roo" && !baseValues.jobToken?.trim()) { toast.error("Roo Code Cloud Token is required") return } - // Determine which selections to use based on provider const selectionsToLaunch: { model: string; configName?: string }[] = [] if (provider === "other") { - // For import mode, use config selections for (const config of configSelections) { if (config.configName) { selectionsToLaunch.push({ model: "", configName: config.configName }) } } } else { - // For openrouter/roo, use model selections for (const selection of modelSelections) { if (selection.model) { selectionsToLaunch.push({ model: selection.model }) @@ -347,20 +445,18 @@ export function NewRun() { return } - // Show launching toast const totalRuns = selectionsToLaunch.length toast.info(totalRuns > 1 ? `Launching ${totalRuns} runs (every 20 seconds)...` : "Launching run...") - // Launch runs with 20-second delay between each for (let i = 0; i < selectionsToLaunch.length; i++) { const selection = selectionsToLaunch[i]! - // Wait 20 seconds between runs (except for the first one) + // Wait 20 seconds between runs (except for the first one). if (i > 0) { - await new Promise((resolve) => setTimeout(resolve, 20000)) + await new Promise((resolve) => setTimeout(resolve, 20_000)) } - const runValues = { ...values } + const runValues = { ...baseValues } if (provider === "openrouter") { runValues.model = selection.model @@ -403,13 +499,14 @@ export function NewRun() { } } - // Navigate back to main evals UI router.push("/") } catch (e) { toast.error(e instanceof Error ? e.message : "An unknown error occurred.") } }, [ + suite, + selectedExercises, provider, modelSelections, configSelections, @@ -442,18 +539,15 @@ export function NewRun() { }) .parse(JSON.parse(await file.text())) - // Store all imported configs for user selection setImportedSettings({ apiConfigs: providerProfiles.apiConfigs, globalSettings, currentApiConfigName: providerProfiles.currentApiConfigName, }) - // Default to the current config for the first selection const defaultConfigName = providerProfiles.currentApiConfigName setConfigSelections([{ id: crypto.randomUUID(), configName: defaultConfigName, popoverOpen: false }]) - // Apply the default config const providerSettings = providerProfiles.apiConfigs[defaultConfigName] ?? {} setValue("model", getModelId(providerSettings) ?? "") setValue("settings", { ...EVALS_SETTINGS, ...providerSettings, ...globalSettings }) @@ -971,6 +1065,36 @@ export function NewRun() { + {/* Execution Method */} + ( + + Execution Method + { + const newExecutionMethod = value as ExecutionMethod + setExecutionMethod(newExecutionMethod) + setValue("executionMethod", newExecutionMethod) + }}> + + + + VSCode + + + + CLI + + + + + + )} + /> + { + it("uses selectedExercises for partial suite", () => { + const result = normalizeCreateRunForSubmit( + { + model: "roo/model-a", + description: "", + suite: "partial", + exercises: [], + settings: undefined, + concurrency: 1, + timeout: 5, + iterations: 1, + jobToken: "", + executionMethod: "vscode", + }, + ["js/foo", "py/bar"], + ) + + expect(result.suite).toBe("partial") + expect(result.exercises).toEqual(["js/foo", "py/bar"]) + }) + + it("dedupes selectedExercises for partial suite", () => { + const result = normalizeCreateRunForSubmit( + { + model: "roo/model-a", + description: "", + suite: "partial", + exercises: [], + settings: undefined, + concurrency: 1, + timeout: 5, + iterations: 1, + jobToken: "", + executionMethod: "vscode", + }, + ["js/foo", "js/foo", "py/bar"], + ) + + expect(result.exercises).toEqual(["js/foo", "py/bar"]) + }) + + it("clears exercises for full suite", () => { + const result = normalizeCreateRunForSubmit( + { + model: "roo/model-a", + description: "", + suite: "full", + exercises: ["js/foo"], + settings: undefined, + concurrency: 1, + timeout: 5, + iterations: 1, + jobToken: "", + executionMethod: "vscode", + }, + ["js/foo"], + ) + + expect(result.suite).toBe("full") + expect(result.exercises).toEqual([]) + }) +}) diff --git a/apps/web-evals/src/lib/__tests__/roo-last-model-selection.spec.ts b/apps/web-evals/src/lib/__tests__/roo-last-model-selection.spec.ts new file mode 100644 index 0000000000..45879b4be5 --- /dev/null +++ b/apps/web-evals/src/lib/__tests__/roo-last-model-selection.spec.ts @@ -0,0 +1,78 @@ +import { + loadRooLastModelSelection, + ROO_LAST_MODEL_SELECTION_KEY, + saveRooLastModelSelection, +} from "../roo-last-model-selection" + +class LocalStorageMock implements Storage { + private store = new Map() + + get length(): number { + return this.store.size + } + + clear(): void { + this.store.clear() + } + + getItem(key: string): string | null { + return this.store.get(key) ?? null + } + + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null + } + + removeItem(key: string): void { + this.store.delete(key) + } + + setItem(key: string, value: string): void { + this.store.set(key, value) + } +} + +beforeEach(() => { + Object.defineProperty(globalThis, "localStorage", { + value: new LocalStorageMock(), + configurable: true, + }) +}) + +describe("roo-last-model-selection", () => { + it("saves and loads (deduped + trimmed)", () => { + saveRooLastModelSelection([" roo/model-a ", "roo/model-a", "roo/model-b"]) + expect(loadRooLastModelSelection()).toEqual(["roo/model-a", "roo/model-b"]) + }) + + it("ignores invalid JSON", () => { + localStorage.setItem(ROO_LAST_MODEL_SELECTION_KEY, "{this is not json") + expect(loadRooLastModelSelection()).toEqual([]) + }) + + it("clears when empty", () => { + localStorage.setItem(ROO_LAST_MODEL_SELECTION_KEY, JSON.stringify(["roo/model-a"])) + saveRooLastModelSelection([]) + expect(localStorage.getItem(ROO_LAST_MODEL_SELECTION_KEY)).toBeNull() + }) + + it("does not throw if localStorage access fails", () => { + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: () => { + throw new Error("blocked") + }, + setItem: () => { + throw new Error("blocked") + }, + removeItem: () => { + throw new Error("blocked") + }, + }, + configurable: true, + }) + + expect(() => loadRooLastModelSelection()).not.toThrow() + expect(() => saveRooLastModelSelection(["roo/model-a"])).not.toThrow() + }) +}) diff --git a/apps/web-evals/src/lib/normalize-create-run.ts b/apps/web-evals/src/lib/normalize-create-run.ts new file mode 100644 index 0000000000..a5f21ba5ad --- /dev/null +++ b/apps/web-evals/src/lib/normalize-create-run.ts @@ -0,0 +1,20 @@ +import type { CreateRun } from "./schemas" + +/** + * The New Run UI keeps exercise selection in component state. + * This normalizer ensures we submit the *visible/selected* exercises when suite is partial. + */ +export function normalizeCreateRunForSubmit( + values: CreateRun, + selectedExercises: string[], + suiteOverride?: CreateRun["suite"], +): CreateRun { + const suite = suiteOverride ?? values.suite + const normalizedSelectedExercises = Array.from(new Set(selectedExercises)) + + return { + ...values, + suite, + exercises: suite === "partial" ? normalizedSelectedExercises : [], + } +} diff --git a/apps/web-evals/src/lib/roo-last-model-selection.ts b/apps/web-evals/src/lib/roo-last-model-selection.ts new file mode 100644 index 0000000000..b66d493172 --- /dev/null +++ b/apps/web-evals/src/lib/roo-last-model-selection.ts @@ -0,0 +1,76 @@ +import { z } from "zod" + +export const ROO_LAST_MODEL_SELECTION_KEY = "evals-roo-last-model-selection" + +const modelIdListSchema = z.array(z.string()) + +function hasLocalStorage(): boolean { + try { + return typeof localStorage !== "undefined" + } catch { + return false + } +} + +function safeGetItem(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function safeSetItem(key: string, value: string): void { + try { + localStorage.setItem(key, value) + } catch { + // ignore + } +} + +function safeRemoveItem(key: string): void { + try { + localStorage.removeItem(key) + } catch { + // ignore + } +} + +function tryParseJson(raw: string | null): unknown { + if (raw === null) return undefined + try { + return JSON.parse(raw) + } catch { + return undefined + } +} + +function normalizeModelIds(modelIds: string[]): string[] { + const unique = new Set() + for (const id of modelIds) { + const trimmed = id.trim() + if (trimmed) unique.add(trimmed) + } + return Array.from(unique) +} + +export function loadRooLastModelSelection(): string[] { + if (!hasLocalStorage()) return [] + + const parsed = modelIdListSchema.safeParse(tryParseJson(safeGetItem(ROO_LAST_MODEL_SELECTION_KEY))) + if (!parsed.success) return [] + + return normalizeModelIds(parsed.data) +} + +export function saveRooLastModelSelection(modelIds: string[]): void { + if (!hasLocalStorage()) return + + const normalized = normalizeModelIds(modelIds) + if (normalized.length === 0) { + safeRemoveItem(ROO_LAST_MODEL_SELECTION_KEY) + return + } + + safeSetItem(ROO_LAST_MODEL_SELECTION_KEY, JSON.stringify(normalized)) +} diff --git a/apps/web-evals/src/lib/schemas.ts b/apps/web-evals/src/lib/schemas.ts index 478c328aa2..fd9250e262 100644 --- a/apps/web-evals/src/lib/schemas.ts +++ b/apps/web-evals/src/lib/schemas.ts @@ -2,6 +2,13 @@ import { z } from "zod" import { rooCodeSettingsSchema } from "@roo-code/types" +/** + * ExecutionMethod + */ + +export const executionMethodSchema = z.enum(["vscode", "cli"]) +export type ExecutionMethod = z.infer + /** * CreateRun */ @@ -29,6 +36,7 @@ export const createRunSchema = z timeout: z.number().int().min(TIMEOUT_MIN).max(TIMEOUT_MAX), iterations: z.number().int().min(ITERATIONS_MIN).max(ITERATIONS_MAX), jobToken: z.string().optional(), + executionMethod: executionMethodSchema, }) .refine((data) => data.suite === "full" || (data.exercises || []).length > 0, { message: "Exercises are required when running a partial suite.", diff --git a/apps/web-roo-code/src/app/privacy/page.tsx b/apps/web-roo-code/src/app/privacy/page.tsx index 905e6ff1a0..e34dffd7f2 100644 --- a/apps/web-roo-code/src/app/privacy/page.tsx +++ b/apps/web-roo-code/src/app/privacy/page.tsx @@ -283,7 +283,8 @@ export default function Privacy() {

  • Delete your Cloud account at any time from{" "} - Security Settings inside Roo Code Cloud. + Security Settings inside Roo Code Cloud (User Menu → My Settings + → Open Profile).
  • Marketing communications: You can unsubscribe from marketing and diff --git a/apps/web-roo-code/src/components/homepage/features.tsx b/apps/web-roo-code/src/components/homepage/features.tsx index fd7bb6114a..b78f76db21 100644 --- a/apps/web-roo-code/src/components/homepage/features.tsx +++ b/apps/web-roo-code/src/components/homepage/features.tsx @@ -39,7 +39,7 @@ export const features: Feature[] = [ icon: CheckCheck, title: "Granular auto-approval", description: - "Control each action and make Roo as autonomous as you want as you build confidence. Or go YOLO and let it rip.", + "Control each action and make Roo as autonomous as you want as you build confidence. Or go BRRR and let it rip.", }, { icon: Boxes, diff --git a/apps/web-roo-code/src/components/homepage/pillars-section.tsx b/apps/web-roo-code/src/components/homepage/pillars-section.tsx index b363a5e931..c6c47ea054 100644 --- a/apps/web-roo-code/src/components/homepage/pillars-section.tsx +++ b/apps/web-roo-code/src/components/homepage/pillars-section.tsx @@ -181,7 +181,7 @@ export function PillarsSection() {

    The Roo Code Extension is{" "} - + open source {" "} so you can see for yourself exactly what it's doing and we don't use diff --git a/locales/ca/README.md b/locales/ca/README.md index b8feb8a99a..05c5dc09b7 100644 --- a/locales/ca/README.md +++ b/locales/ca/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-mode | | | | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Instal·lant Roo Code |
    Configurant perfils |
    Indexació de la base de codi | -|
    Modes personalitzats |
    Punts de control |
    Llistes de tasques | +|
    Modes personalitzats |
    Punts de control |
    Gestió de Context |

    diff --git a/locales/de/README.md b/locales/de/README.md index d63dfdf955..8ab42086d5 100644 --- a/locales/de/README.md +++ b/locales/de/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes | | | | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Roo Code installieren |
    Profile konfigurieren |
    Codebasis-Indizierung | -|
    Benutzerdefinierte Modi |
    Checkpoints |
    Todo-Listen | +|
    Benutzerdefinierte Modi |
    Checkpoints |
    Kontextverwaltung |

    diff --git a/locales/es/README.md b/locales/es/README.md index af7666d106..e7e5aad467 100644 --- a/locales/es/README.md +++ b/locales/es/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [M | | | | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Instalando Roo Code |
    Configurando perfiles |
    Indexación de la base de código | -|
    Modos personalizados |
    Checkpoints |
    Listas de Tareas | +|
    Modos personalizados |
    Checkpoints |
    Gestión de Contexto |

    diff --git a/locales/fr/README.md b/locales/fr/README.md index b92535f9e7..546b669f66 100644 --- a/locales/fr/README.md +++ b/locales/fr/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Installer Roo Code |
    Configurer les profils |
    Indexation de la base de code | -|
    Modes personnalisés |
    Checkpoints |
    Listes de tâches | +|
    Modes personnalisés |
    Checkpoints |
    Gestion du Contexte |

    diff --git a/locales/hi/README.md b/locales/hi/README.md index 4499feac72..f84e32fd66 100644 --- a/locales/hi/README.md +++ b/locales/hi/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    रू कोड इंस्टॉल करना |
    प्रोफाइल कॉन्फ़िगर करना |
    कोडबेस इंडेक्सिंग | -|
    कस्टम मोड |
    चेकपॉइंट्स |
    टू-डू लिस्ट | +|
    कस्टम मोड |
    चेकपॉइंट्स |
    संदर्भ प्रबंधन |

    diff --git a/locales/id/README.md b/locales/id/README.md index 9c46a5ca2e..0d74501f8c 100644 --- a/locales/id/README.md +++ b/locales/id/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/u | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Menginstal Roo Code |
    Mengonfigurasi Profil |
    Pengindeksan Basis Kode | -|
    Mode Kustom |
    Pos Pemeriksaan |
    Daftar Tugas | +|
    Mode Kustom |
    Pos Pemeriksaan |
    Manajemen Konteks |

    diff --git a/locales/it/README.md b/locales/it/README.md index 71bbda7c81..8bc8692e8b 100644 --- a/locales/it/README.md +++ b/locales/it/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using- | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Installazione di Roo Code |
    Configurazione dei profili |
    Indicizzazione della codebase | -|
    Modalità personalizzate |
    Checkpoint |
    Elenchi di cose da fare | +|
    Modalità personalizzate |
    Checkpoint |
    Gestione del Contesto |

    diff --git a/locales/ja/README.md b/locales/ja/README.md index 1bf753546a..2456c0cf41 100644 --- a/locales/ja/README.md +++ b/locales/ja/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Roo Codeは、あなたの働き方に合わせるように適応します。 | | | | | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Roo Codeのインストール |
    プロファイルの設定 |
    コードベースのインデックス作成 | -|
    カスタムモード |
    チェックポイント |
    ToDoリスト | +|
    カスタムモード |
    チェックポイント |
    コンテキスト管理 |

    diff --git a/locales/ko/README.md b/locales/ko/README.md index 8b8f45ebb5..758c7eda95 100644 --- a/locales/ko/README.md +++ b/locales/ko/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Roo Code는 당신의 작업 방식에 맞춰 적응합니다. | | | | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Roo Code 설치하기 |
    프로필 구성하기 |
    코드베이스 인덱싱 | -|
    사용자 지정 모드 |
    체크포인트 |
    할 일 목록 | +|
    사용자 지정 모드 |
    체크포인트 |
    컨텍스트 관리 |

    diff --git a/locales/nl/README.md b/locales/nl/README.md index 983318aa81..92172552b3 100644 --- a/locales/nl/README.md +++ b/locales/nl/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Meer info: [Modi gebruiken](https://docs.roocode.com/basic-usage/using-modes) | | | | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Roo Code installeren |
    Profielen configureren |
    Codebase indexeren | -|
    Aangepaste modi |
    Checkpoints |
    To-Do Lijsten | +|
    Aangepaste modi |
    Checkpoints |
    Contextbeheer |

    diff --git a/locales/pl/README.md b/locales/pl/README.md index 94e23b2980..c1d0fc395f 100644 --- a/locales/pl/README.md +++ b/locales/pl/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-mode | | | | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Instalacja Roo Code |
    Konfiguracja profili |
    Indeksowanie bazy kodu | -|
    Tryby niestandardowe |
    Punkty kontrolne |
    Listy zadań | +|
    Tryby niestandardowe |
    Punkty kontrolne |
    Zarządzanie Kontekstem |

    diff --git a/locales/pt-BR/README.md b/locales/pt-BR/README.md index abca12ffb7..215fd01ad1 100644 --- a/locales/pt-BR/README.md +++ b/locales/pt-BR/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [ | | | | | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Instalando o Roo Code |
    Configurando perfis |
    Indexação da base de código | -|
    Modos personalizados |
    Checkpoints |
    Listas de tarefas | +|
    Modos personalizados |
    Checkpoints |
    Gerenciamento de Contexto |

    diff --git a/locales/ru/README.md b/locales/ru/README.md index d2689da5cf..02f79658fc 100644 --- a/locales/ru/README.md +++ b/locales/ru/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Roo Code адаптируется к вашему стилю работы, а н | | | | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Установка Roo Code |
    Настройка профилей |
    Индексация кодовой базы | -|
    Пользовательские режимы |
    Контрольные точки |
    Списки дел | +|
    Пользовательские режимы |
    Контрольные точки |
    Управление Контекстом |

    diff --git a/locales/tr/README.md b/locales/tr/README.md index c095677851..df15ac0eb1 100644 --- a/locales/tr/README.md +++ b/locales/tr/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -66,10 +66,10 @@ Daha fazla: [Modları kullanma](https://docs.roocode.com/basic-usage/using-modes

    -| | | | -| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
    Roo Code Kurulumu |
    Profilleri Yapılandırma |
    Kod Tabanı İndeksleme | -|
    Özel Modlar |
    Kontrol Noktaları |
    Yapılacaklar Listeleri | +| | | | +| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
    Roo Code Kurulumu |
    Profilleri Yapılandırma |
    Kod Tabanı İndeksleme | +|
    Özel Modlar |
    Kontrol Noktaları |
    Bağlam Yönetimi |

    diff --git a/locales/vi/README.md b/locales/vi/README.md index 5b8100eb2b..d46dc80298 100644 --- a/locales/vi/README.md +++ b/locales/vi/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -66,10 +66,10 @@ Xem thêm: [Sử dụng Chế độ](https://docs.roocode.com/basic-usage/using-

    -| | | | -| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
    Cài đặt Roo Code |
    Định cấu hình Hồ sơ |
    Lập chỉ mục cơ sở mã | -|
    Chế độ tùy chỉnh |
    Điểm kiểm tra |
    Danh sách việc cần làm | +| | | | +| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
    Cài đặt Roo Code |
    Định cấu hình Hồ sơ |
    Lập chỉ mục cơ sở mã | +|
    Chế độ tùy chỉnh |
    Điểm kiểm tra |
    Quản lý Ngữ cảnh |

    diff --git a/locales/zh-CN/README.md b/locales/zh-CN/README.md index 162e787a2b..6a0e8868db 100644 --- a/locales/zh-CN/README.md +++ b/locales/zh-CN/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -66,10 +66,10 @@ Roo Code 适应您的工作方式,而不是相反:

    -| | | | -| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
    安装 Roo Code |
    配置个人资料 |
    代码库索引 | -|
    自定义模式 |
    检查点 |
    待办事项列表 | +| | | | +| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
    安装 Roo Code |
    配置个人资料 |
    代码库索引 | +|
    自定义模式 |
    检查点 |
    上下文管理 |

    diff --git a/locales/zh-TW/README.md b/locales/zh-TW/README.md index 05d46aca68..e7ab1d4609 100644 --- a/locales/zh-TW/README.md +++ b/locales/zh-TW/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -69,7 +69,7 @@ Roo Code 適應您的工作方式,而不是相反: | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    安裝 Roo Code |
    設定設定檔 |
    程式碼庫索引 | -|
    自訂模式 |
    檢查點 |
    待辦事項清單 | +|
    自訂模式 |
    檢查點 |
    上下文管理 |

    diff --git a/package.json b/package.json index 0f3c3b7ba0..d4ea31991d 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,9 @@ ] }, "pnpm": { + "onlyBuiltDependencies": [ + "@vscode/ripgrep" + ], "overrides": { "tar-fs": ">=3.1.1", "esbuild": ">=0.25.0", diff --git a/packages/cloud/src/WebAuthService.ts b/packages/cloud/src/WebAuthService.ts index 0b5c108e89..69ad28e8ec 100644 --- a/packages/cloud/src/WebAuthService.ts +++ b/packages/cloud/src/WebAuthService.ts @@ -331,10 +331,15 @@ export class WebAuthService extends EventEmitter implements A await this.storeCredentials(credentials) - // Store the provider model if provided + // Store the provider model if provided, or flag that no model was selected if (providerModel) { await this.context.globalState.update("roo-provider-model", providerModel) + await this.context.globalState.update("roo-auth-skip-model", undefined) this.log(`[auth] Stored provider model: ${providerModel}`) + } else { + // No model was selected during signup - flag this for the webview + await this.context.globalState.update("roo-auth-skip-model", true) + this.log(`[auth] No provider model selected during signup`) } const vscode = await importVscode() diff --git a/packages/cloud/src/__tests__/WebAuthService.spec.ts b/packages/cloud/src/__tests__/WebAuthService.spec.ts index 2f9528dec5..3398e3f2a3 100644 --- a/packages/cloud/src/__tests__/WebAuthService.spec.ts +++ b/packages/cloud/src/__tests__/WebAuthService.spec.ts @@ -395,9 +395,38 @@ describe("WebAuthService", () => { await authService.handleCallback("auth-code", storedState, null, "xai/grok-code-fast-1") expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-provider-model", "xai/grok-code-fast-1") + expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-auth-skip-model", undefined) expect(mockLog).toHaveBeenCalledWith("[auth] Stored provider model: xai/grok-code-fast-1") }) + it("should set skip model flag when provider model is NOT provided in callback", async () => { + const storedState = "valid-state" + mockContext.globalState.get.mockReturnValue(storedState) + + // Mock successful Clerk sign-in response + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + response: { created_session_id: "session-123" }, + }), + headers: { + get: (header: string) => (header === "authorization" ? "Bearer token-123" : null), + }, + } + mockFetch.mockResolvedValue(mockResponse) + + const vscode = await import("vscode") + const mockShowInfo = vi.fn() + vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) + + // Call without provider model + await authService.handleCallback("auth-code", storedState, null) + + expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-auth-skip-model", true) + expect(mockLog).toHaveBeenCalledWith("[auth] No provider model selected during signup") + }) + it("should handle Clerk API errors", async () => { const storedState = "valid-state" mockContext.globalState.get.mockReturnValue(storedState) diff --git a/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts index 78581fc7c5..affb1a3734 100644 --- a/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts +++ b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts @@ -2,7 +2,7 @@ import fs from "fs" import os from "os" import path from "path" -import { getEsbuildScriptPath, runEsbuild } from "../esbuild-runner.js" +import { getEsbuildScriptPath, runEsbuild, NODE_BUILTIN_MODULES, COMMONJS_REQUIRE_BANNER } from "../esbuild-runner.js" describe("getEsbuildScriptPath", () => { it("should find esbuild-wasm script in node_modules in development", () => { @@ -153,4 +153,101 @@ describe("runEsbuild", () => { // File should be created successfully. expect(fs.existsSync(outputFile)).toBe(true) }, 30000) + + it("should keep external modules as imports instead of bundling", async () => { + const inputFile = path.join(tempDir, "input.ts") + const outputFile = path.join(tempDir, "output.mjs") + + // Write code that imports fs (a Node.js built-in). + fs.writeFileSync( + inputFile, + ` + import fs from "fs" + export function fileExists(p: string): boolean { + return fs.existsSync(p) + } + `, + ) + + await runEsbuild({ + entryPoint: inputFile, + outfile: outputFile, + format: "esm", + bundle: true, + external: ["fs"], + }) + + const outputContent = fs.readFileSync(outputFile, "utf-8") + // fs should remain as an import, not bundled. + expect(outputContent).toMatch(/import.*from\s*["']fs["']/) + }, 30000) + + it("should add banner code when specified", async () => { + const inputFile = path.join(tempDir, "input.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync(inputFile, `export const greeting = "Hello"`) + + const customBanner = "// This is a custom banner comment" + await runEsbuild({ + entryPoint: inputFile, + outfile: outputFile, + format: "esm", + banner: customBanner, + }) + + const outputContent = fs.readFileSync(outputFile, "utf-8") + // Banner should be at the start of the file. + expect(outputContent.startsWith(customBanner)).toBe(true) + }, 30000) + + it("should add CommonJS require shim banner for ESM bundles", async () => { + const inputFile = path.join(tempDir, "input.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync(inputFile, `export const value = 42`) + + await runEsbuild({ + entryPoint: inputFile, + outfile: outputFile, + format: "esm", + banner: COMMONJS_REQUIRE_BANNER, + }) + + const outputContent = fs.readFileSync(outputFile, "utf-8") + // Should contain the createRequire shim. + expect(outputContent).toContain("createRequire") + expect(outputContent).toContain("import.meta.url") + }, 30000) +}) + +describe("NODE_BUILTIN_MODULES", () => { + it("should include common Node.js built-in modules", () => { + expect(NODE_BUILTIN_MODULES).toContain("fs") + expect(NODE_BUILTIN_MODULES).toContain("path") + expect(NODE_BUILTIN_MODULES).toContain("crypto") + expect(NODE_BUILTIN_MODULES).toContain("http") + expect(NODE_BUILTIN_MODULES).toContain("https") + expect(NODE_BUILTIN_MODULES).toContain("os") + expect(NODE_BUILTIN_MODULES).toContain("child_process") + expect(NODE_BUILTIN_MODULES).toContain("stream") + expect(NODE_BUILTIN_MODULES).toContain("util") + expect(NODE_BUILTIN_MODULES).toContain("events") + }) + + it("should be an array of strings", () => { + expect(Array.isArray(NODE_BUILTIN_MODULES)).toBe(true) + expect(NODE_BUILTIN_MODULES.every((m) => typeof m === "string")).toBe(true) + }) +}) + +describe("COMMONJS_REQUIRE_BANNER", () => { + it("should provide createRequire shim", () => { + expect(COMMONJS_REQUIRE_BANNER).toContain("createRequire") + expect(COMMONJS_REQUIRE_BANNER).toContain("import.meta.url") + }) + + it("should define require variable", () => { + expect(COMMONJS_REQUIRE_BANNER).toMatch(/var require\s*=/) + }) }) diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts index ee72f68d8e..1725f4aba3 100644 --- a/packages/core/src/custom-tools/custom-tool-registry.ts +++ b/packages/core/src/custom-tools/custom-tool-registry.ts @@ -17,7 +17,7 @@ import type { CustomToolDefinition, SerializedCustomToolDefinition, CustomToolPa import type { StoredCustomTool, LoadResult } from "./types.js" import { serializeCustomTool } from "./serialize.js" -import { runEsbuild } from "./esbuild-runner.js" +import { runEsbuild, NODE_BUILTIN_MODULES, COMMONJS_REQUIRE_BANNER } from "./esbuild-runner.js" export interface RegistryOptions { /** Directory for caching compiled TypeScript files. */ @@ -236,16 +236,22 @@ export class CustomToolRegistry { /** * Clear the TypeScript compilation cache (both in-memory and on disk). + * This removes all tool-specific subdirectories and their contents. */ clearCache(): void { this.tsCache.clear() if (fs.existsSync(this.cacheDir)) { try { - const files = fs.readdirSync(this.cacheDir) - for (const file of files) { - if (file.endsWith(".mjs")) { - fs.unlinkSync(path.join(this.cacheDir, file)) + const entries = fs.readdirSync(this.cacheDir, { withFileTypes: true }) + for (const entry of entries) { + const entryPath = path.join(this.cacheDir, entry.name) + if (entry.isDirectory()) { + // Remove tool-specific subdirectory and all its contents. + fs.rmSync(entryPath, { recursive: true, force: true }) + } else if (entry.name.endsWith(".mjs")) { + // Also clean up any legacy flat .mjs files from older cache format. + fs.unlinkSync(entryPath) } } } catch (error) { @@ -259,6 +265,11 @@ export class CustomToolRegistry { /** * Dynamically import a TypeScript or JavaScript file. * TypeScript files are transpiled on-the-fly using esbuild. + * + * For TypeScript files, esbuild bundles the code with these considerations: + * - Node.js built-in modules (fs, path, etc.) are kept external + * - npm packages are bundled with a CommonJS shim for require() compatibility + * - The tool's local node_modules is included in the resolution path */ private async import(filePath: string): Promise> { const absolutePath = path.resolve(filePath) @@ -277,11 +288,13 @@ export class CustomToolRegistry { return import(`file://${cachedPath}`) } - // Ensure cache directory exists. - fs.mkdirSync(this.cacheDir, { recursive: true }) - const hash = createHash("sha256").update(cacheKey).digest("hex").slice(0, 16) - const tempFile = path.join(this.cacheDir, `${hash}.mjs`) + + // Use a tool-specific subdirectory to avoid .env file conflicts between tools. + const toolCacheDir = path.join(this.cacheDir, hash) + fs.mkdirSync(toolCacheDir, { recursive: true }) + + const tempFile = path.join(toolCacheDir, "bundle.mjs") // Check if we have a cached version on disk (from a previous run/instance). if (fs.existsSync(tempFile)) { @@ -289,7 +302,17 @@ export class CustomToolRegistry { return import(`file://${tempFile}`) } + // Get the tool's directory to include its node_modules in resolution path. + const toolDir = path.dirname(absolutePath) + const toolNodeModules = path.join(toolDir, "node_modules") + + // Combine default nodePaths with tool-specific node_modules. + // Tool's node_modules takes priority (listed first). + const nodePaths = fs.existsSync(toolNodeModules) ? [toolNodeModules, ...this.nodePaths] : this.nodePaths + // Bundle the TypeScript file with dependencies using esbuild CLI. + // - Node.js built-ins are external (they can't be bundled and are always available) + // - npm packages are bundled with CommonJS require() shim for compatibility await runEsbuild( { entryPoint: absolutePath, @@ -300,15 +323,54 @@ export class CustomToolRegistry { bundle: true, sourcemap: "inline", packages: "bundle", - nodePaths: this.nodePaths, + nodePaths, + external: NODE_BUILTIN_MODULES, + banner: COMMONJS_REQUIRE_BANNER, }, this.extensionPath, ) + // Copy .env files from the tool's source directory to the tool-specific cache directory. + // This allows tools that use dotenv with __dirname to find their .env files, + // while ensuring different tools' .env files don't overwrite each other. + this.copyEnvFiles(toolDir, toolCacheDir) + this.tsCache.set(cacheKey, tempFile) return import(`file://${tempFile}`) } + /** + * Copy .env files from the tool's source directory to the tool-specific cache directory. + * This allows tools that use dotenv with __dirname to find their .env files, + * while ensuring different tools' .env files don't overwrite each other. + * + * @param toolDir - The directory containing the tool source files + * @param destDir - The tool-specific cache directory to copy .env files to + */ + private copyEnvFiles(toolDir: string, destDir: string): void { + try { + const files = fs.readdirSync(toolDir) + const envFiles = files.filter((f) => f === ".env" || f.startsWith(".env.")) + + for (const envFile of envFiles) { + const srcPath = path.join(toolDir, envFile) + const destPath = path.join(destDir, envFile) + + // Only copy if source is a file (not a directory). + const stat = fs.statSync(srcPath) + if (stat.isFile()) { + fs.copyFileSync(srcPath, destPath) + console.log(`[CustomToolRegistry] copied ${envFile} to tool cache directory`) + } + } + } catch (error) { + // Non-fatal: log but don't fail if we can't copy env files. + console.warn( + `[CustomToolRegistry] failed to copy .env files: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + /** * Check if a value is a Zod schema by looking for the _def property * which is present on all Zod types. diff --git a/packages/core/src/custom-tools/esbuild-runner.ts b/packages/core/src/custom-tools/esbuild-runner.ts index 4138478921..6f62143551 100644 --- a/packages/core/src/custom-tools/esbuild-runner.ts +++ b/packages/core/src/custom-tools/esbuild-runner.ts @@ -11,9 +11,27 @@ import path from "path" import fs from "fs" +import { builtinModules } from "module" import { fileURLToPath } from "url" import { execa } from "execa" +/** + * Node.js built-in modules that should never be bundled. + * These are always available in Node.js runtime and bundling them causes issues. + * + * Uses Node.js's authoritative list from `module.builtinModules` and adds + * the `node:` prefixed versions for comprehensive coverage. + */ +export const NODE_BUILTIN_MODULES: readonly string[] = [...builtinModules, ...builtinModules.map((m) => `node:${m}`)] + +/** + * Banner code to add to bundled output. + * This provides a CommonJS-compatible `require` function for ESM bundles, + * which is needed when bundled npm packages use `require()` internally. + */ +export const COMMONJS_REQUIRE_BANNER = `import { createRequire as __roo_createRequire } from 'module'; +var require = __roo_createRequire(import.meta.url);` + // Get the directory where this module is located. function getModuleDir(): string | undefined { try { @@ -50,6 +68,10 @@ export interface EsbuildOptions { packages?: "bundle" | "external" /** Additional paths for module resolution */ nodePaths?: string[] + /** Modules to exclude from bundling (resolved at runtime) */ + external?: readonly string[] + /** JavaScript code to prepend to the output bundle */ + banner?: string } /** @@ -158,6 +180,18 @@ export async function runEsbuild(options: EsbuildOptions, extensionPath?: string args.push(`--packages=${options.packages}`) } + // Add external modules - these won't be bundled and will be resolved at runtime. + if (options.external && options.external.length > 0) { + for (const ext of options.external) { + args.push(`--external:${ext}`) + } + } + + // Add banner code (e.g., for CommonJS require shim in ESM bundles). + if (options.banner) { + args.push(`--banner:js=${options.banner}`) + } + // Build environment with NODE_PATH for module resolution. const env: NodeJS.ProcessEnv = { ...process.env } diff --git a/packages/evals/Dockerfile.runner b/packages/evals/Dockerfile.runner index 19a85c51d0..5d8e113206 100644 --- a/packages/evals/Dockerfile.runner +++ b/packages/evals/Dockerfile.runner @@ -1,14 +1,14 @@ -FROM node:20-slim AS base +# Build with: +# docker compose -f packages/evals/docker-compose.yml build runner -# Install pnpm -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -RUN npm install -g npm@latest npm-run-all +# Test with: +# docker compose -f packages/evals/docker-compose.yml run --rm runner bash -# Install system packages -RUN apt update && \ - apt install -y \ +FROM debian:bookworm-slim AS base + +# Install system packages (excluding language runtimes - those come from mise) +RUN apt-get update && \ + apt-get install -y \ curl \ git \ vim \ @@ -22,18 +22,13 @@ RUN apt update && \ gpg \ xvfb \ cmake \ - golang-go \ - default-jre \ - python3 \ - python3-venv \ - python3-dev \ - python3-pip \ + build-essential \ && rm -rf /var/lib/apt/lists/* # Install Docker cli RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \ - && apt update && apt install -y docker-ce-cli \ + && apt-get update && apt-get install -y docker-ce-cli \ && rm -rf /var/lib/apt/lists/* # Install VS Code @@ -41,15 +36,43 @@ RUN wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor && install -D -o root -g root -m 644 packages.microsoft.gpg /etc/apt/keyrings/packages.microsoft.gpg \ && echo "deb [arch=amd64,arm64,armhf signed-by=/etc/apt/keyrings/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main" | tee /etc/apt/sources.list.d/vscode.list > /dev/null \ && rm -f packages.microsoft.gpg \ - && apt update && apt install -y code \ + && apt-get update && apt-get install -y code \ && rm -rf /var/lib/apt/lists/* WORKDIR /roo -# Install rust -ARG RUST_VERSION=1.87.0 -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain ${RUST_VERSION} \ - && echo 'source $HOME/.cargo/env' >> $HOME/.bashrc +# Install mise (https://mise.jdx.dev) for language runtime management +RUN curl https://mise.run | sh \ + && /root/.local/bin/mise --version + +# Set up mise environment +ENV MISE_DATA_DIR="/root/.local/share/mise" +ENV PATH="/root/.local/share/mise/shims:/root/.local/bin:$PATH" + +# Define language runtime versions (matching setup.sh) +ARG NODE_VERSION=20.19.2 +ARG PYTHON_VERSION=3.13.2 +ARG GO_VERSION=1.24.2 +ARG RUST_VERSION=1.85.1 +ARG JAVA_VERSION=openjdk-17 +ARG UV_VERSION=0.7.11 + +# Install language runtimes via mise +RUN mise use --global node@${NODE_VERSION} \ + && mise use --global python@${PYTHON_VERSION} \ + && mise use --global go@${GO_VERSION} \ + && mise use --global rust@${RUST_VERSION} \ + && mise use --global java@${JAVA_VERSION} \ + && mise use --global uv@${UV_VERSION} \ + && mise reshim + +# Verify installations +RUN node --version && python --version && go version && rustc --version && java --version && uv --version + +# Install pnpm (after node is available from mise) +ENV PNPM_HOME="/root/.local/share/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN npm install -g pnpm npm-run-all # Install VS Code extensions ARG GOLANG_EXT_VERSION=0.46.1 @@ -72,17 +95,20 @@ RUN git clone ${EVALS_REPO_URL} evals \ && cd evals \ && git checkout ${EVALS_COMMIT} -# Install uv and sync python dependencies -ARG UV_VERSION=0.7.11 +# Pre-warm Gradle wrapper cache (./gradlew downloads its own Gradle regardless of system install). +# Find a Java project with gradlew and run it to cache the distribution. +RUN find /roo/evals -name "gradlew" -type f | head -1 | xargs -I {} sh -c 'cd $(dirname {}) && ./gradlew --version' + +# Sync python dependencies for evals WORKDIR /roo/evals/python -RUN curl -LsSf https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-installer.sh | sh \ - && /root/.local/bin/uv sync +RUN uv sync WORKDIR /roo/repo # Install npm packages RUN mkdir -p \ scripts \ + apps/cli \ packages/build \ packages/config-eslint \ packages/config-typescript \ @@ -92,6 +118,7 @@ RUN mkdir -p \ packages/telemetry \ packages/types \ packages/cloud \ + packages/vscode-shim \ src \ webview-ui @@ -99,6 +126,7 @@ COPY ./package.json ./ COPY ./pnpm-lock.yaml ./ COPY ./pnpm-workspace.yaml ./ COPY ./scripts/bootstrap.mjs ./scripts/ +COPY ./apps/cli/package.json ./apps/cli/ COPY ./packages/build/package.json ./packages/build/ COPY ./packages/config-eslint/package.json ./packages/config-eslint/ COPY ./packages/config-typescript/package.json ./packages/config-typescript/ @@ -108,6 +136,7 @@ COPY ./packages/ipc/package.json ./packages/ipc/ COPY ./packages/telemetry/package.json ./packages/telemetry/ COPY ./packages/types/package.json ./packages/types/ COPY ./packages/cloud/package.json ./packages/cloud/ +COPY ./packages/vscode-shim/package.json ./packages/vscode-shim/ COPY ./src/package.json ./src/ COPY ./webview-ui/package.json ./webview-ui/ @@ -128,10 +157,15 @@ COPY packages/evals/.env.local ./packages/evals/ # Copy the pre-installed VS Code extensions RUN cp -r /roo/.vscode-template /roo/.vscode -# Build the Roo Code extension +# Build the Roo Code extension (for VSCode execution method) RUN pnpm vsix -- --out ../bin/roo-code.vsix \ && yes | code --no-sandbox --user-data-dir /roo/.vscode --install-extension bin/roo-code.vsix +# Build the extension bundle and CLI (for CLI execution method) +# The CLI requires the extension bundle (src/dist/extension.js) and the CLI build (apps/cli/dist/index.js) +RUN pnpm --filter roo-cline bundle \ + && pnpm --filter @roo-code/cli build + # Copy entrypoint script COPY packages/evals/.docker/entrypoints/runner.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh diff --git a/packages/evals/src/cli/messageLogDeduper.test.ts b/packages/evals/src/cli/__tests__/messageLogDeduper.test.ts similarity index 95% rename from packages/evals/src/cli/messageLogDeduper.test.ts rename to packages/evals/src/cli/__tests__/messageLogDeduper.test.ts index 5556c0c850..3a7facb8c2 100644 --- a/packages/evals/src/cli/messageLogDeduper.test.ts +++ b/packages/evals/src/cli/__tests__/messageLogDeduper.test.ts @@ -1,4 +1,4 @@ -import { MessageLogDeduper } from "./messageLogDeduper.js" +import { MessageLogDeduper } from "../messageLogDeduper.js" describe("MessageLogDeduper", () => { it("dedupes identical messages for same action+ts", () => { diff --git a/packages/evals/src/cli/index.ts b/packages/evals/src/cli/index.ts index f7c343de2f..bc91f0db8a 100644 --- a/packages/evals/src/cli/index.ts +++ b/packages/evals/src/cli/index.ts @@ -6,7 +6,7 @@ import { EVALS_REPO_PATH } from "../exercises/index.js" import { runCi } from "./runCi.js" import { runEvals } from "./runEvals.js" -import { processTask } from "./runTask.js" +import { processTask } from "./processTask.js" const main = async () => { await run( diff --git a/packages/evals/src/cli/processTask.ts b/packages/evals/src/cli/processTask.ts new file mode 100644 index 0000000000..c0348872cc --- /dev/null +++ b/packages/evals/src/cli/processTask.ts @@ -0,0 +1,150 @@ +import { execa } from "execa" + +import { type TaskEvent, RooCodeEventName } from "@roo-code/types" + +import { findRun, findTask, updateTask } from "../db/index.js" + +import { Logger, getTag, isDockerContainer } from "./utils.js" +import { redisClient, getPubSubKey, registerRunner, deregisterRunner } from "./redis.js" +import { runUnitTest } from "./runUnitTest.js" +import { runTaskWithCli } from "./runTaskInCli.js" +import { runTaskInVscode } from "./runTaskInVscode.js" + +export const processTask = async ({ + taskId, + jobToken, + logger, +}: { + taskId: number + jobToken: string | null + logger?: Logger +}) => { + const task = await findTask(taskId) + const { language, exercise } = task + const run = await findRun(task.runId) + await registerRunner({ runId: run.id, taskId, timeoutSeconds: (run.timeout || 5) * 60 }) + + const containerized = isDockerContainer() + + logger = + logger || + new Logger({ + logDir: containerized ? `/var/log/evals/runs/${run.id}` : `/tmp/evals/runs/${run.id}`, + filename: `${language}-${exercise}.log`, + tag: getTag("runTask", { run, task }), + }) + + try { + const publish = async (e: TaskEvent) => { + const redis = await redisClient() + await redis.publish(getPubSubKey(run.id), JSON.stringify(e)) + } + + const executionMethod = run.executionMethod || "vscode" + logger.info(`running task ${task.id} (${language}/${exercise}) via ${executionMethod}...`) + + if (executionMethod === "cli") { + await runTaskWithCli({ run, task, jobToken, publish, logger }) + } else { + await runTaskInVscode({ run, task, jobToken, publish, logger }) + } + + logger.info(`testing task ${task.id} (${language}/${exercise})...`) + const passed = await runUnitTest({ task, logger }) + + logger.info(`task ${task.id} (${language}/${exercise}) -> ${passed}`) + await updateTask(task.id, { passed }) + + await publish({ + eventName: passed ? RooCodeEventName.EvalPass : RooCodeEventName.EvalFail, + taskId: task.id, + }) + } finally { + await deregisterRunner({ runId: run.id, taskId }) + } +} + +export const processTaskInContainer = async ({ + taskId, + jobToken, + logger, + maxRetries = 10, +}: { + taskId: number + jobToken: string | null + logger: Logger + maxRetries?: number +}) => { + const baseArgs = [ + "--rm", + "--network evals_default", + "-v /var/run/docker.sock:/var/run/docker.sock", + "-v /tmp/evals:/var/log/evals", + "-e HOST_EXECUTION_METHOD=docker", + ] + + if (jobToken) { + baseArgs.push(`-e ROO_CODE_CLOUD_TOKEN=${jobToken}`) + } + + // Pass API keys to the container so the CLI can authenticate + const apiKeyEnvVars = [ + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "DEEPSEEK_API_KEY", + "MISTRAL_API_KEY", + ] + + for (const envVar of apiKeyEnvVars) { + if (process.env[envVar]) { + baseArgs.push(`-e ${envVar}=${process.env[envVar]}`) + } + } + + const command = `pnpm --filter @roo-code/evals cli --taskId ${taskId}` + logger.info(command) + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const containerName = `evals-task-${taskId}.${attempt}` + const args = [`--name ${containerName}`, `-e EVALS_ATTEMPT=${attempt}`, ...baseArgs] + const isRetry = attempt > 0 + + if (isRetry) { + const delayMs = Math.pow(2, attempt - 1) * 1000 * (0.5 + Math.random()) + logger.info(`retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + + logger.info( + `${isRetry ? "retrying" : "executing"} container command (attempt ${attempt + 1}/${maxRetries + 1})`, + ) + + const subprocess = execa(`docker run ${args.join(" ")} evals-runner sh -c "${command}"`, { shell: true }) + // subprocess.stdout?.on("data", (data) => console.log(data.toString())) + // subprocess.stderr?.on("data", (data) => console.error(data.toString())) + + try { + const result = await subprocess + logger.info(`container process completed with exit code: ${result.exitCode}`) + return + } catch (error) { + if (error && typeof error === "object" && "exitCode" in error) { + logger.error( + `container process failed with exit code: ${error.exitCode} (attempt ${attempt + 1}/${maxRetries + 1})`, + ) + } else { + logger.error(`container process failed with error: ${error} (attempt ${attempt + 1}/${maxRetries + 1})`) + } + + if (attempt === maxRetries) { + break + } + } + } + + logger.error(`all ${maxRetries + 1} attempts failed, giving up`) + + // TODO: Mark task as failed. +} diff --git a/packages/evals/src/cli/runEvals.ts b/packages/evals/src/cli/runEvals.ts index 7fe6d7ea4e..cb327938ea 100644 --- a/packages/evals/src/cli/runEvals.ts +++ b/packages/evals/src/cli/runEvals.ts @@ -5,7 +5,7 @@ import { EVALS_REPO_PATH } from "../exercises/index.js" import { Logger, getTag, isDockerContainer, resetEvalsRepo, commitEvalsRepoChanges } from "./utils.js" import { startHeartbeat, stopHeartbeat } from "./redis.js" -import { processTask, processTaskInContainer } from "./runTask.js" +import { processTask, processTaskInContainer } from "./processTask.js" export const runEvals = async (runId: number) => { const run = await findRun(runId) @@ -53,13 +53,18 @@ export const runEvals = async (runId: number) => { } try { - // Add tasks with staggered start times when concurrency > 1 + // Add tasks with staggered start times when concurrency > 1. for (let i = 0; i < filteredTasks.length; i++) { const task = filteredTasks[i] - if (!task) continue + + if (!task) { + continue + } + if (run.concurrency > 1 && i > 0) { await new Promise((resolve) => setTimeout(resolve, STAGGER_DELAY_MS)) } + queue.add(createTaskRunner(task)) } diff --git a/packages/evals/src/cli/runTaskInCli.ts b/packages/evals/src/cli/runTaskInCli.ts new file mode 100644 index 0000000000..1f1ad79161 --- /dev/null +++ b/packages/evals/src/cli/runTaskInCli.ts @@ -0,0 +1,313 @@ +import * as fs from "fs" +import * as path from "path" +import * as os from "node:os" + +import pWaitFor from "p-wait-for" +import { execa } from "execa" + +import { type ToolUsage, TaskCommandName, RooCodeEventName, IpcMessageType } from "@roo-code/types" +import { IpcClient } from "@roo-code/ipc" + +import { updateTask, createTaskMetrics, updateTaskMetrics, createToolError } from "../db/index.js" +import { EVALS_REPO_PATH } from "../exercises/index.js" + +import { type RunTaskOptions } from "./types.js" +import { mergeToolUsage, waitForSubprocessWithTimeout } from "./utils.js" + +/** + * Run a task using the Roo Code CLI (headless mode). + * Uses the same IPC protocol as VSCode since the CLI loads the same extension bundle. + */ +export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: RunTaskOptions) => { + const { language, exercise } = task + const prompt = fs.readFileSync(path.resolve(EVALS_REPO_PATH, `prompts/${language}.md`), "utf-8") + const workspacePath = path.resolve(EVALS_REPO_PATH, language, exercise) + const ipcSocketPath = path.resolve(os.tmpdir(), `evals-cli-${run.id}-${task.id}.sock`) + + const env: Record = { + ...(process.env as Record), + ROO_CODE_IPC_SOCKET_PATH: ipcSocketPath, + } + + if (jobToken) { + env.ROO_CODE_CLOUD_TOKEN = jobToken + } + + const controller = new AbortController() + const cancelSignal = controller.signal + + const cliArgs = [ + "--filter", + "@roo-code/cli", + "start", + "--yes", + "--exit-on-complete", + "--reasoning-effort", + "disabled", + "--workspace", + workspacePath, + ] + + if (run.settings?.mode) { + cliArgs.push("-M", run.settings.mode) + } + + if (run.settings?.apiProvider) { + cliArgs.push("-p", run.settings.apiProvider) + } + + const modelId = run.settings?.apiModelId || run.settings?.openRouterModelId + + if (modelId) { + cliArgs.push("-m", modelId) + } + + cliArgs.push(prompt) + + logger.info(`CLI command: pnpm ${cliArgs.join(" ")}`) + + const subprocess = execa("pnpm", cliArgs, { env, cancelSignal, cwd: process.cwd() }) + + // Buffer for accumulating streaming output until we have complete lines. + let stdoutBuffer = "" + let stderrBuffer = "" + + // Track subprocess exit code - with -x flag the CLI exits immediately after task completion. + let subprocessExitCode: number | null = null + + // Pipe CLI stdout/stderr to the logger for easier debugging. + // Buffer output and only log complete lines to avoid fragmented token-by-token logging. + // Use logger.raw() to output without the verbose prefix (timestamp, tag, etc). + subprocess.stdout?.on("data", (data: Buffer) => { + stdoutBuffer += data.toString() + const lines = stdoutBuffer.split("\n") + + // Keep the last incomplete line in the buffer. + stdoutBuffer = lines.pop() || "" + + // Log all complete lines without the verbose prefix. + for (const line of lines) { + if (line.trim()) { + logger.raw(line) + } + } + }) + + subprocess.stderr?.on("data", (data: Buffer) => { + stderrBuffer += data.toString() + const lines = stderrBuffer.split("\n") + + // Keep the last incomplete line in the buffer. + stderrBuffer = lines.pop() || "" + + // Log all complete lines without the verbose prefix. + for (const line of lines) { + if (line.trim()) { + logger.raw(line) + } + } + }) + + // Log any remaining buffered output when the subprocess exits. + subprocess.on("exit", (code) => { + subprocessExitCode = code + + if (stdoutBuffer.trim()) { + logger.raw(stdoutBuffer) + } + + if (stderrBuffer.trim()) { + logger.raw(stderrBuffer) + } + }) + + // Give CLI some time to start and create IPC server. + await new Promise((resolve) => setTimeout(resolve, 5_000)) + + let client: IpcClient | undefined = undefined + let attempts = 10 // More attempts for CLI startup. + + while (true) { + try { + client = new IpcClient(ipcSocketPath) + await pWaitFor(() => client!.isReady, { interval: 500, timeout: 2_000 }) + break + } catch (_error) { + client?.disconnect() + attempts-- + + if (attempts <= 0) { + logger.error(`unable to connect to IPC socket -> ${ipcSocketPath}`) + throw new Error("Unable to connect to CLI IPC socket.") + } + + // Wait a bit before retrying. + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + } + + // For CLI mode, we need to create taskMetrics immediately because the CLI starts + // the task right away (from command line args). By the time we connect to IPC, + // the TaskStarted event may have already been sent and missed. + // This is different from VSCode mode where we send StartNewTask via IPC and can + // reliably receive TaskStarted. + const taskMetrics = await createTaskMetrics({ + cost: 0, + tokensIn: 0, + tokensOut: 0, + tokensContext: 0, + duration: 0, + cacheWrites: 0, + cacheReads: 0, + }) + + await updateTask(task.id, { taskMetricsId: taskMetrics.id, startedAt: new Date() }) + logger.info(`created taskMetrics with id ${taskMetrics.id}`) + + // The rest of the logic handles IPC events for metrics updates. + let taskStartedAt = Date.now() + let taskFinishedAt: number | undefined + let taskAbortedAt: number | undefined + let taskTimedOut: boolean = false + const taskMetricsId = taskMetrics.id // Already set, no need to wait for TaskStarted. + let rooTaskId: string | undefined + let isClientDisconnected = false + const accumulatedToolUsage: ToolUsage = {} + + // For CLI mode, we don't need verbose IPC message logging since we're logging stdout instead. + // We only track what's needed for metrics and task state management. + const ignoreEventsForBroadcast = [RooCodeEventName.Message] + let isApiUnstable = false + + client.on(IpcMessageType.TaskEvent, async (taskEvent) => { + const { eventName, payload } = taskEvent + + // Track API instability for retry logic. + if ( + eventName === RooCodeEventName.Message && + payload[0].message.say && + ["api_req_retry_delayed", "api_req_retried"].includes(payload[0].message.say) + ) { + isApiUnstable = true + } + + // Publish events to Redis (except Message events) for the web UI. + if (!ignoreEventsForBroadcast.includes(eventName)) { + await publish({ ...taskEvent, taskId: task.id }) + } + + // Handle task lifecycle events. + // For CLI mode, we already created taskMetrics before connecting to IPC, + // but we still want to capture the rooTaskId from TaskStarted if we receive it. + if (eventName === RooCodeEventName.TaskStarted) { + taskStartedAt = Date.now() + rooTaskId = payload[0] + logger.info(`received TaskStarted event, rooTaskId: ${rooTaskId}`) + } + + if (eventName === RooCodeEventName.TaskToolFailed) { + const [_taskId, toolName, error] = payload + await createToolError({ taskId: task.id, toolName, error }) + } + + if (eventName === RooCodeEventName.TaskTokenUsageUpdated || eventName === RooCodeEventName.TaskCompleted) { + // In CLI mode, taskMetricsId is always set before we register event handlers. + const duration = Date.now() - taskStartedAt + + const { totalCost, totalTokensIn, totalTokensOut, contextTokens, totalCacheWrites, totalCacheReads } = + payload[1] + + const incomingToolUsage: ToolUsage = payload[2] ?? {} + mergeToolUsage(accumulatedToolUsage, incomingToolUsage) + + await updateTaskMetrics(taskMetricsId, { + cost: totalCost, + tokensIn: totalTokensIn, + tokensOut: totalTokensOut, + tokensContext: contextTokens, + duration, + cacheWrites: totalCacheWrites ?? 0, + cacheReads: totalCacheReads ?? 0, + toolUsage: accumulatedToolUsage, + }) + } + + if (eventName === RooCodeEventName.TaskAborted) { + taskAbortedAt = Date.now() + } + + if (eventName === RooCodeEventName.TaskCompleted) { + taskFinishedAt = Date.now() + } + }) + + client.on(IpcMessageType.Disconnect, async () => { + logger.info(`disconnected from IPC socket -> ${ipcSocketPath}`) + isClientDisconnected = true + // Note: In CLI mode, we don't need to resolve taskMetricsReady since + // taskMetrics is created synchronously before event handlers are registered. + }) + + // Note: We do NOT send StartNewTask via IPC here because the CLI already + // starts the task from its command line arguments. The IPC connection is + // only used to receive events (TaskStarted, TaskCompleted, etc.) and metrics. + // Sending StartNewTask here would start a SECOND task. + + try { + const timeoutMs = (run.timeout || 5) * 60 * 1_000 + + await pWaitFor(() => !!taskFinishedAt || !!taskAbortedAt || isClientDisconnected, { + interval: 1_000, + timeout: timeoutMs, + }) + } catch (_error) { + taskTimedOut = true + logger.error("time limit reached") + + if (rooTaskId && !isClientDisconnected) { + logger.info("cancelling task") + client.sendCommand({ commandName: TaskCommandName.CancelTask, data: rooTaskId }) + await new Promise((resolve) => setTimeout(resolve, 5_000)) + } + + taskFinishedAt = Date.now() + } + + if (!taskFinishedAt && !taskTimedOut) { + // With -x flag, CLI exits immediately after task completion, which can cause + // IPC disconnection before we receive the TaskCompleted event. + // If subprocess exited cleanly (code 0), treat as successful completion. + if (subprocessExitCode === 0) { + taskFinishedAt = Date.now() + logger.info("subprocess exited cleanly (code 0), treating as task completion") + } else { + logger.error(`client disconnected before task finished (subprocess exit code: ${subprocessExitCode})`) + throw new Error("Client disconnected before task completion.") + } + } + + logger.info("setting task finished at") + await updateTask(task.id, { finishedAt: new Date() }) + + if (rooTaskId && !isClientDisconnected) { + logger.info("closing task") + client.sendCommand({ commandName: TaskCommandName.CloseTask, data: rooTaskId }) + await new Promise((resolve) => setTimeout(resolve, 2_000)) + } + + if (!isClientDisconnected) { + logger.info("disconnecting client") + client.disconnect() + } + + logger.info("waiting for subprocess to finish") + controller.abort() + + await waitForSubprocessWithTimeout({ subprocess, logger }) + + logger.close() + + if (isApiUnstable && !taskFinishedAt) { + throw new Error("API is unstable, throwing to trigger a retry.") + } +} diff --git a/packages/evals/src/cli/runTask.ts b/packages/evals/src/cli/runTaskInVscode.ts similarity index 58% rename from packages/evals/src/cli/runTask.ts rename to packages/evals/src/cli/runTaskInVscode.ts index a6ae6c0305..f6e87a4bda 100644 --- a/packages/evals/src/cli/runTask.ts +++ b/packages/evals/src/cli/runTaskInVscode.ts @@ -1,5 +1,4 @@ import * as fs from "fs" -import * as fsp from "fs/promises" import * as path from "path" import * as os from "node:os" @@ -7,218 +6,23 @@ import pWaitFor from "p-wait-for" import { execa } from "execa" import { - type TaskEvent, type ClineSay, + type ToolUsage, TaskCommandName, RooCodeEventName, IpcMessageType, EVALS_SETTINGS, - type ToolUsage, } from "@roo-code/types" import { IpcClient } from "@roo-code/ipc" -import { - type Run, - type Task, - findRun, - findTask, - updateTask, - createTaskMetrics, - updateTaskMetrics, - createToolError, -} from "../db/index.js" +import { updateTask, createTaskMetrics, updateTaskMetrics, createToolError } from "../db/index.js" import { EVALS_REPO_PATH } from "../exercises/index.js" -import { Logger, getTag, isDockerContainer } from "./utils.js" -import { redisClient, getPubSubKey, registerRunner, deregisterRunner } from "./redis.js" -import { runUnitTest } from "./runUnitTest.js" +import { type RunTaskOptions } from "./types.js" +import { isDockerContainer, copyConversationHistory, mergeToolUsage, waitForSubprocessWithTimeout } from "./utils.js" import { MessageLogDeduper } from "./messageLogDeduper.js" -class SubprocessTimeoutError extends Error { - constructor(timeout: number) { - super(`Subprocess timeout after ${timeout}ms`) - this.name = "SubprocessTimeoutError" - } -} - -/** - * Copy conversation history files from VS Code extension storage to the log directory. - * This allows us to preserve the api_conversation_history.json and ui_messages.json - * files for post-mortem analysis alongside the log files. - */ -async function copyConversationHistory({ - rooTaskId, - logDir, - language, - exercise, - iteration, - logger, -}: { - rooTaskId: string - logDir: string - language: string - exercise: string - iteration: number - logger: Logger -}): Promise { - // VS Code extension global storage path within the container - const extensionStoragePath = "/roo/.vscode/User/globalStorage/rooveterinaryinc.roo-cline" - const taskStoragePath = path.join(extensionStoragePath, "tasks", rooTaskId) - - const filesToCopy = ["api_conversation_history.json", "ui_messages.json"] - - for (const filename of filesToCopy) { - const sourcePath = path.join(taskStoragePath, filename) - // Use sanitized exercise name (replace slashes with dashes) for the destination filename - // Include iteration number to handle multiple attempts at the same exercise - const sanitizedExercise = exercise.replace(/\//g, "-") - const destFilename = `${language}-${sanitizedExercise}.${iteration}_${filename}` - const destPath = path.join(logDir, destFilename) - - try { - // Check if source file exists - await fsp.access(sourcePath) - - // Copy the file - await fsp.copyFile(sourcePath, destPath) - logger.info(`copied ${filename} to ${destPath}`) - } catch (error) { - // File may not exist if task didn't complete properly - this is not fatal - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - logger.info(`${filename} not found at ${sourcePath} - skipping`) - } else { - logger.error(`failed to copy ${filename}:`, error) - } - } - } -} - -export const processTask = async ({ - taskId, - jobToken, - logger, -}: { - taskId: number - jobToken: string | null - logger?: Logger -}) => { - const task = await findTask(taskId) - const { language, exercise } = task - const run = await findRun(task.runId) - await registerRunner({ runId: run.id, taskId, timeoutSeconds: (run.timeout || 5) * 60 }) - - const containerized = isDockerContainer() - - logger = - logger || - new Logger({ - logDir: containerized ? `/var/log/evals/runs/${run.id}` : `/tmp/evals/runs/${run.id}`, - filename: `${language}-${exercise}.log`, - tag: getTag("runTask", { run, task }), - }) - - try { - const publish = async (e: TaskEvent) => { - const redis = await redisClient() - await redis.publish(getPubSubKey(run.id), JSON.stringify(e)) - } - - logger.info(`running task ${task.id} (${language}/${exercise})...`) - await runTask({ run, task, jobToken, publish, logger }) - - logger.info(`testing task ${task.id} (${language}/${exercise})...`) - const passed = await runUnitTest({ task, logger }) - - logger.info(`task ${task.id} (${language}/${exercise}) -> ${passed}`) - await updateTask(task.id, { passed }) - - await publish({ - eventName: passed ? RooCodeEventName.EvalPass : RooCodeEventName.EvalFail, - taskId: task.id, - }) - } finally { - await deregisterRunner({ runId: run.id, taskId }) - } -} - -export const processTaskInContainer = async ({ - taskId, - jobToken, - logger, - maxRetries = 10, -}: { - taskId: number - jobToken: string | null - logger: Logger - maxRetries?: number -}) => { - const baseArgs = [ - "--rm", - "--network evals_default", - "-v /var/run/docker.sock:/var/run/docker.sock", - "-v /tmp/evals:/var/log/evals", - "-e HOST_EXECUTION_METHOD=docker", - ] - - if (jobToken) { - baseArgs.push(`-e ROO_CODE_CLOUD_TOKEN=${jobToken}`) - } - - const command = `pnpm --filter @roo-code/evals cli --taskId ${taskId}` - logger.info(command) - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - const containerName = `evals-task-${taskId}.${attempt}` - const args = [`--name ${containerName}`, `-e EVALS_ATTEMPT=${attempt}`, ...baseArgs] - const isRetry = attempt > 0 - - if (isRetry) { - const delayMs = Math.pow(2, attempt - 1) * 1000 * (0.5 + Math.random()) - logger.info(`retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`) - await new Promise((resolve) => setTimeout(resolve, delayMs)) - } - - logger.info( - `${isRetry ? "retrying" : "executing"} container command (attempt ${attempt + 1}/${maxRetries + 1})`, - ) - - const subprocess = execa(`docker run ${args.join(" ")} evals-runner sh -c "${command}"`, { shell: true }) - // subprocess.stdout?.on("data", (data) => console.log(data.toString())) - // subprocess.stderr?.on("data", (data) => console.error(data.toString())) - - try { - const result = await subprocess - logger.info(`container process completed with exit code: ${result.exitCode}`) - return - } catch (error) { - if (error && typeof error === "object" && "exitCode" in error) { - logger.error( - `container process failed with exit code: ${error.exitCode} (attempt ${attempt + 1}/${maxRetries + 1})`, - ) - } else { - logger.error(`container process failed with error: ${error} (attempt ${attempt + 1}/${maxRetries + 1})`) - } - - if (attempt === maxRetries) { - break - } - } - } - - logger.error(`all ${maxRetries + 1} attempts failed, giving up`) - - // TODO: Mark task as failed. -} - -type RunTaskOptions = { - run: Run - task: Task - jobToken: string | null - publish: (taskEvent: TaskEvent) => Promise - logger: Logger -} - -export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskOptions) => { +export const runTaskInVscode = async ({ run, task, publish, logger, jobToken }: RunTaskOptions) => { const { language, exercise } = task const prompt = fs.readFileSync(path.resolve(EVALS_REPO_PATH, `prompts/${language}.md`), "utf-8") const workspacePath = path.resolve(EVALS_REPO_PATH, language, exercise) @@ -301,6 +105,7 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO "diff_error", "condense_context", "condense_context_error", + "api_req_rate_limit_wait", "api_req_retry_delayed", "api_req_retried", ] @@ -409,24 +214,7 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO // For both TaskTokenUsageUpdated and TaskCompleted: toolUsage is payload[2] const incomingToolUsage: ToolUsage = payload[2] ?? {} - - // Merge incoming tool usage with accumulated data using MAX strategy. - // This handles the case where a task is rehydrated after abort: - // - Empty rehydrated data won't overwrite existing: max(5, 0) = 5 - // - Legitimate restart with additional work is captured: max(5, 8) = 8 - // Each task instance tracks its own cumulative values, so we take the max - // to preserve the highest values seen across all instances. - for (const [toolName, usage] of Object.entries(incomingToolUsage)) { - const existing = accumulatedToolUsage[toolName as keyof ToolUsage] - if (existing) { - accumulatedToolUsage[toolName as keyof ToolUsage] = { - attempts: Math.max(existing.attempts, usage.attempts), - failures: Math.max(existing.failures, usage.failures), - } - } else { - accumulatedToolUsage[toolName as keyof ToolUsage] = { ...usage } - } - } + mergeToolUsage(accumulatedToolUsage, incomingToolUsage) await updateTaskMetrics(taskMetricsId, { cost: totalCost, @@ -513,35 +301,7 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO logger.info("waiting for subprocess to finish") controller.abort() - // Wait for subprocess to finish gracefully, with a timeout. - const SUBPROCESS_TIMEOUT = 10_000 - - try { - await Promise.race([ - subprocess, - new Promise((_, reject) => - setTimeout(() => reject(new SubprocessTimeoutError(SUBPROCESS_TIMEOUT)), SUBPROCESS_TIMEOUT), - ), - ]) - - logger.info("subprocess finished gracefully") - } catch (error) { - if (error instanceof SubprocessTimeoutError) { - logger.error("subprocess did not finish within timeout, force killing") - - try { - if (subprocess.kill("SIGKILL")) { - logger.info("SIGKILL sent to subprocess") - } else { - logger.error("failed to send SIGKILL to subprocess") - } - } catch (killError) { - logger.error("subprocess.kill(SIGKILL) failed:", killError) - } - } else { - throw error - } - } + await waitForSubprocessWithTimeout({ subprocess, logger }) // Copy conversation history files from VS Code extension storage to the log directory // for post-mortem analysis. Only do this in containerized mode where we have a known path. diff --git a/packages/evals/src/cli/types.ts b/packages/evals/src/cli/types.ts new file mode 100644 index 0000000000..bb6012ddeb --- /dev/null +++ b/packages/evals/src/cli/types.ts @@ -0,0 +1,19 @@ +import { type TaskEvent } from "@roo-code/types" + +import type { Run, Task } from "../db/index.js" +import { Logger } from "./utils.js" + +export class SubprocessTimeoutError extends Error { + constructor(timeout: number) { + super(`Subprocess timeout after ${timeout}ms`) + this.name = "SubprocessTimeoutError" + } +} + +export type RunTaskOptions = { + run: Run + task: Task + jobToken: string | null + publish: (taskEvent: TaskEvent) => Promise + logger: Logger +} diff --git a/packages/evals/src/cli/utils.ts b/packages/evals/src/cli/utils.ts index bf1489d09b..49064efa6a 100644 --- a/packages/evals/src/cli/utils.ts +++ b/packages/evals/src/cli/utils.ts @@ -1,10 +1,15 @@ import * as fs from "fs" +import * as fsp from "fs/promises" import * as path from "path" -import { execa } from "execa" +import { execa, type ResultPromise } from "execa" + +import type { ToolUsage } from "@roo-code/types" import type { Run, Task } from "../db/index.js" +import { SubprocessTimeoutError } from "./types.js" + export const getTag = (caller: string, { run, task }: { run: Run; task?: Task }) => task ? `${caller} | pid:${process.pid} | run:${run.id} | task:${task.id} | ${task.language}/${task.exercise}` @@ -107,6 +112,22 @@ export class Logger { this.info(message, ...args) } + /** + * Write raw output without any prefix (timestamp, level, tag). + * Useful for streaming CLI output where the prefix would be noise. + */ + public raw(message: string): void { + try { + console.log(message) + + if (this.logStream) { + this.logStream.write(message + "\n") + } + } catch (error) { + console.error(`Failed to write to log file ${this.logFilePath}:`, error) + } + } + public close(): void { if (this.logStream) { this.logStream.end() @@ -114,3 +135,117 @@ export class Logger { } } } + +/** + * Copy conversation history files from VS Code extension storage to the log directory. + * This allows us to preserve the api_conversation_history.json and ui_messages.json + * files for post-mortem analysis alongside the log files. + */ +export async function copyConversationHistory({ + rooTaskId, + logDir, + language, + exercise, + iteration, + logger, +}: { + rooTaskId: string + logDir: string + language: string + exercise: string + iteration: number + logger: Logger +}): Promise { + // VS Code extension global storage path within the container + const extensionStoragePath = "/roo/.vscode/User/globalStorage/rooveterinaryinc.roo-cline" + const taskStoragePath = path.join(extensionStoragePath, "tasks", rooTaskId) + + const filesToCopy = ["api_conversation_history.json", "ui_messages.json"] + + for (const filename of filesToCopy) { + const sourcePath = path.join(taskStoragePath, filename) + // Use sanitized exercise name (replace slashes with dashes) for the destination filename + // Include iteration number to handle multiple attempts at the same exercise + const sanitizedExercise = exercise.replace(/\//g, "-") + const destFilename = `${language}-${sanitizedExercise}.${iteration}_${filename}` + const destPath = path.join(logDir, destFilename) + + try { + // Check if source file exists + await fsp.access(sourcePath) + + // Copy the file + await fsp.copyFile(sourcePath, destPath) + logger.info(`copied ${filename} to ${destPath}`) + } catch (error) { + // File may not exist if task didn't complete properly - this is not fatal + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + logger.info(`${filename} not found at ${sourcePath} - skipping`) + } else { + logger.error(`failed to copy ${filename}:`, error) + } + } + } +} + +/** + * Merge incoming tool usage with accumulated data using MAX strategy. + * This handles the case where a task is rehydrated after abort: + * - Empty rehydrated data won't overwrite existing: max(5, 0) = 5 + * - Legitimate restart with additional work is captured: max(5, 8) = 8 + * Each task instance tracks its own cumulative values, so we take the max + * to preserve the highest values seen across all instances. + */ +export function mergeToolUsage(accumulated: ToolUsage, incoming: ToolUsage): void { + for (const [toolName, usage] of Object.entries(incoming)) { + const existing = accumulated[toolName as keyof ToolUsage] + + if (existing) { + accumulated[toolName as keyof ToolUsage] = { + attempts: Math.max(existing.attempts, usage.attempts), + failures: Math.max(existing.failures, usage.failures), + } + } else { + accumulated[toolName as keyof ToolUsage] = { ...usage } + } + } +} + +/** + * Wait for a subprocess to finish gracefully, with a timeout. + * If the subprocess doesn't finish within the timeout, force kill it with SIGKILL. + */ +export async function waitForSubprocessWithTimeout({ + subprocess, + timeoutMs = 10_000, + logger, +}: { + subprocess: ResultPromise + timeoutMs?: number + logger: Logger +}): Promise { + try { + await Promise.race([ + subprocess, + new Promise((_, reject) => setTimeout(() => reject(new SubprocessTimeoutError(timeoutMs)), timeoutMs)), + ]) + + logger.info("subprocess finished gracefully") + } catch (error) { + if (error instanceof SubprocessTimeoutError) { + logger.error("subprocess did not finish within timeout, force killing") + + try { + if (subprocess.kill("SIGKILL")) { + logger.info("SIGKILL sent to subprocess") + } else { + logger.error("failed to send SIGKILL to subprocess") + } + } catch (killError) { + logger.error("subprocess.kill(SIGKILL) failed:", killError) + } + } else { + throw error + } + } +} diff --git a/packages/evals/src/db/migrations/0006_worried_spectrum.sql b/packages/evals/src/db/migrations/0006_worried_spectrum.sql new file mode 100644 index 0000000000..87c199447b --- /dev/null +++ b/packages/evals/src/db/migrations/0006_worried_spectrum.sql @@ -0,0 +1 @@ +ALTER TABLE "runs" ADD COLUMN "execution_method" text DEFAULT 'vscode' NOT NULL; \ No newline at end of file diff --git a/packages/evals/src/db/migrations/meta/0006_snapshot.json b/packages/evals/src/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000000..683ef57702 --- /dev/null +++ b/packages/evals/src/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,479 @@ +{ + "id": "ae1ebc36-8f5b-43e1-8e47-5a63d72ed05f", + "prevId": "71b54967-86df-42ec-a200-bfd8dad85069", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contextWindow": { + "name": "contextWindow", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "inputPrice": { + "name": "inputPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "outputPrice": { + "name": "outputPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cacheWritesPrice": { + "name": "cacheWritesPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cacheReadsPrice": { + "name": "cacheReadsPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "jobToken": { + "name": "jobToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socket_path": { + "name": "socket_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_method": { + "name": "execution_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'vscode'" + }, + "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 + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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_iteration_idx": { + "name": "tasks_language_exercise_iteration_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" + }, + { + "expression": "iteration", + "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": "cascade", + "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": "set null", + "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": "cascade", + "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": "cascade", + "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 fbdfcd79bf..d70ab18782 100644 --- a/packages/evals/src/db/migrations/meta/_journal.json +++ b/packages/evals/src/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1765167049182, "tag": "0005_strong_skrulls", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1767550126096, + "tag": "0006_worried_spectrum", + "breakpoints": true } ] } diff --git a/packages/evals/src/db/schema.ts b/packages/evals/src/db/schema.ts index 5094e64f20..4d159fe29b 100644 --- a/packages/evals/src/db/schema.ts +++ b/packages/evals/src/db/schema.ts @@ -5,6 +5,12 @@ import type { RooCodeSettings, ToolName, ToolUsage } from "@roo-code/types" import type { ExerciseLanguage } from "../exercises/index.js" +/** + * ExecutionMethod + */ + +export type ExecutionMethod = "vscode" | "cli" + /** * runs */ @@ -24,6 +30,7 @@ export const runs = pgTable("runs", { jobToken: text(), pid: integer(), socketPath: text("socket_path").notNull(), + executionMethod: text("execution_method").default("vscode").notNull().$type(), concurrency: integer().default(2).notNull(), timeout: integer().default(5).notNull(), passed: integer().default(0).notNull(), diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 3d52d3c7d7..94f064234e 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.94.0", + "version": "1.99.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index a11fae1e11..9a17834ced 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -143,6 +143,7 @@ export const globalSettingsSchema = z.object({ maxOpenTabsContext: z.number().optional(), maxWorkspaceFiles: z.number().optional(), showRooIgnoredFiles: z.boolean().optional(), + enableSubfolderRules: z.boolean().optional(), maxReadFileLine: z.number().optional(), maxImageFileSize: z.number().optional(), maxTotalImageSize: z.number().optional(), @@ -291,7 +292,6 @@ export const isGlobalStateKey = (key: string): key is Keys => // Default settings when running evals (unless overridden). export const EVALS_SETTINGS: RooCodeSettings = { apiProvider: "openrouter", - openRouterUseMiddleOutTransform: false, lastShownAnnouncementId: "jul-09-2025-3-23-0", diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index d97884d216..b4d84cb9a5 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -29,6 +29,7 @@ export const historyItemSchema = z.object({ * This ensures task resumption works correctly even when NTC settings change. */ toolProtocol: z.enum(["xml", "native"]).optional(), + apiConfigName: z.string().optional(), // Provider profile name for sticky profile feature status: z.enum(["active", "completed", "delegated"]).optional(), delegatedToId: z.string().optional(), // Last child this parent delegated to childIds: z.array(z.string()).optional(), // All children spawned by this task diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 77ad604206..c4e5088d63 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -17,7 +17,6 @@ export * from "./message.js" export * from "./mode.js" export * from "./model.js" export * from "./provider-settings.js" -export * from "./single-file-read-models.js" export * from "./task.js" export * from "./todo.js" export * from "./telemetry.js" diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 82f58f29f2..109cd842ba 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -129,6 +129,7 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk { * - `api_req_finished`: Indicates an API request has completed successfully * - `api_req_retried`: Indicates an API request is being retried after a failure * - `api_req_retry_delayed`: Indicates an API request retry has been delayed + * - `api_req_rate_limit_wait`: Indicates a configured rate-limit wait (not an error) * - `api_req_deleted`: Indicates an API request has been deleted/cancelled * - `text`: General text message or assistant response * - `reasoning`: Assistant's reasoning or thought process (often hidden from user) @@ -155,6 +156,7 @@ export const clineSays = [ "api_req_finished", "api_req_retried", "api_req_retry_delayed", + "api_req_rate_limit_wait", "api_req_deleted", "text", "image", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 443cbafcf1..294e9f22e3 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -102,7 +102,7 @@ export const isCustomProvider = (key: string): key is CustomProvider => customPr * model lists. */ -export const fauxProviders = ["fake-ai", "human-relay"] as const +export const fauxProviders = ["fake-ai"] as const export type FauxProvider = (typeof fauxProviders)[number] @@ -207,7 +207,6 @@ const openRouterSchema = baseProviderSettingsSchema.extend({ openRouterModelId: z.string().optional(), openRouterBaseUrl: z.string().optional(), openRouterSpecificProvider: z.string().optional(), - openRouterUseMiddleOutTransform: z.boolean().optional(), }) const bedrockSchema = apiModelIdProviderModelSchema.extend({ @@ -345,8 +344,6 @@ const requestySchema = baseProviderSettingsSchema.extend({ requestyModelId: z.string().optional(), }) -const humanRelaySchema = baseProviderSettingsSchema - const fakeAiSchema = baseProviderSettingsSchema.extend({ fakeAi: z.unknown().optional(), }) @@ -448,7 +445,6 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv minimaxSchema.merge(z.object({ apiProvider: z.literal("minimax") })), 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") })), fakeAiSchema.merge(z.object({ apiProvider: z.literal("fake-ai") })), xaiSchema.merge(z.object({ apiProvider: z.literal("xai") })), groqSchema.merge(z.object({ apiProvider: z.literal("groq") })), @@ -490,7 +486,6 @@ export const providerSettingsSchema = z.object({ ...minimaxSchema.shape, ...unboundSchema.shape, ...requestySchema.shape, - ...humanRelaySchema.shape, ...fakeAiSchema.shape, ...xaiSchema.shape, ...groqSchema.shape, @@ -628,7 +623,7 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str */ export const MODELS_BY_PROVIDER: Record< - Exclude, + Exclude, { id: ProviderName; label: string; models: string[] } > = { anthropic: { diff --git a/packages/types/src/providers/bedrock.ts b/packages/types/src/providers/bedrock.ts index da40e98f43..19dfbf0b30 100644 --- a/packages/types/src/providers/bedrock.ts +++ b/packages/types/src/providers/bedrock.ts @@ -264,39 +264,6 @@ export const bedrockModels = { inputPrice: 0.25, outputPrice: 1.25, }, - "anthropic.claude-2-1-v1:0": { - maxTokens: 4096, - contextWindow: 100_000, - supportsImages: false, - supportsPromptCache: false, - supportsNativeTools: true, - defaultToolProtocol: "native", - inputPrice: 8.0, - outputPrice: 24.0, - description: "Claude 2.1", - }, - "anthropic.claude-2-0-v1:0": { - maxTokens: 4096, - contextWindow: 100_000, - supportsImages: false, - supportsPromptCache: false, - supportsNativeTools: true, - defaultToolProtocol: "native", - inputPrice: 8.0, - outputPrice: 24.0, - description: "Claude 2.0", - }, - "anthropic.claude-instant-v1:0": { - maxTokens: 4096, - contextWindow: 100_000, - supportsImages: false, - supportsPromptCache: false, - supportsNativeTools: true, - defaultToolProtocol: "native", - inputPrice: 0.8, - outputPrice: 2.4, - description: "Claude Instant", - }, "deepseek.r1-v1:0": { maxTokens: 32_768, contextWindow: 128_000, diff --git a/packages/types/src/providers/cerebras.ts b/packages/types/src/providers/cerebras.ts index 54b314b6db..37c063e83b 100644 --- a/packages/types/src/providers/cerebras.ts +++ b/packages/types/src/providers/cerebras.ts @@ -7,7 +7,7 @@ export const cerebrasDefaultModelId: CerebrasModelId = "gpt-oss-120b" export const cerebrasModels = { "zai-glm-4.6": { - maxTokens: 8192, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront) + maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront) contextWindow: 131072, supportsImages: false, supportsPromptCache: false, @@ -15,10 +15,22 @@ export const cerebrasModels = { defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, - description: "Highly intelligent general purpose model with up to 1,000 tokens/s", + description: "Fast general-purpose model on Cerebras (up to 1,000 tokens/s). To be deprecated soon.", + }, + "zai-glm-4.7": { + maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront) + contextWindow: 131072, + supportsImages: false, + supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", + inputPrice: 0, + outputPrice: 0, + description: + "Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.", }, "qwen-3-235b-a22b-instruct-2507": { - maxTokens: 8192, // Conservative default to avoid premature rate limiting + maxTokens: 16384, // Conservative default to avoid premature rate limiting contextWindow: 64000, supportsImages: false, supportsPromptCache: false, @@ -29,7 +41,7 @@ export const cerebrasModels = { description: "Intelligent model with ~1400 tokens/s", }, "llama-3.3-70b": { - maxTokens: 8192, // Conservative default to avoid premature rate limiting + maxTokens: 16384, // Conservative default to avoid premature rate limiting contextWindow: 64000, supportsImages: false, supportsPromptCache: false, @@ -40,7 +52,7 @@ export const cerebrasModels = { description: "Powerful model with ~2600 tokens/s", }, "qwen-3-32b": { - maxTokens: 8192, // Conservative default to avoid premature rate limiting + maxTokens: 16384, // Conservative default to avoid premature rate limiting contextWindow: 64000, supportsImages: false, supportsPromptCache: false, @@ -51,7 +63,7 @@ export const cerebrasModels = { description: "SOTA coding performance with ~2500 tokens/s", }, "gpt-oss-120b": { - maxTokens: 8192, // Conservative default to avoid premature rate limiting + maxTokens: 16384, // Conservative default to avoid premature rate limiting contextWindow: 64000, supportsImages: false, supportsPromptCache: false, diff --git a/packages/types/src/providers/fireworks.ts b/packages/types/src/providers/fireworks.ts index 1918826ca1..3f7b17034e 100644 --- a/packages/types/src/providers/fireworks.ts +++ b/packages/types/src/providers/fireworks.ts @@ -3,6 +3,7 @@ import type { ModelInfo } from "../model.js" export type FireworksModelId = | "accounts/fireworks/models/kimi-k2-instruct" | "accounts/fireworks/models/kimi-k2-instruct-0905" + | "accounts/fireworks/models/kimi-k2-thinking" | "accounts/fireworks/models/minimax-m2" | "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507" | "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct" @@ -43,6 +44,21 @@ export const fireworksModels = { description: "Kimi K2 is a state-of-the-art mixture-of-experts (MoE) language model with 32 billion activated parameters and 1 trillion total parameters. Trained with the Muon optimizer, Kimi K2 achieves exceptional performance across frontier knowledge, reasoning, and coding tasks while being meticulously optimized for agentic capabilities.", }, + "accounts/fireworks/models/kimi-k2-thinking": { + maxTokens: 16000, + contextWindow: 256000, + supportsImages: false, + supportsPromptCache: true, + supportsNativeTools: true, + supportsTemperature: true, + preserveReasoning: true, + defaultTemperature: 1.0, + inputPrice: 0.6, + outputPrice: 2.5, + cacheReadsPrice: 0.15, + description: + "The kimi-k2-thinking model is a general-purpose agentic reasoning model developed by Moonshot AI. Thanks to its strength in deep reasoning and multi-turn tool use, it can solve even the hardest problems.", + }, "accounts/fireworks/models/minimax-m2": { maxTokens: 4096, contextWindow: 204800, diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index ecd56bd41a..a08d673e22 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -143,7 +143,6 @@ export function getProviderDefaultModelId( return vercelAiGatewayDefaultModelId case "anthropic": case "gemini-cli": - case "human-relay": case "fake-ai": default: return anthropicDefaultModelId diff --git a/packages/types/src/single-file-read-models.ts b/packages/types/src/single-file-read-models.ts deleted file mode 100644 index 302b8d4202..0000000000 --- a/packages/types/src/single-file-read-models.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Configuration for models that should use simplified single-file read_file tool - * These models will use the simpler ... format - * instead of the more complex multi-file args format - */ - -/** - * Check if a model should use single file read format - * @param modelId The model ID to check - * @returns true if the model should use single file reads - */ -export function shouldUseSingleFileRead(modelId: string): boolean { - return modelId.includes("grok-code-fast-1") || modelId.includes("code-supernova") -} diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index be3f49c40f..76e03f8c80 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -37,6 +37,7 @@ export const toolNames = [ "update_todo_list", "run_slash_command", "generate_image", + "custom_tool", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index 98a633671c..fd28f2e994 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -38,11 +38,6 @@ export const commandIds = [ "openInNewTab", - "showHumanRelayDialog", - "registerHumanRelayCallback", - "unregisterHumanRelayCallback", - "handleHumanRelayResponse", - "newTask", "setCustomStoragePath", diff --git a/packages/vscode-shim/eslint.config.mjs b/packages/vscode-shim/eslint.config.mjs new file mode 100644 index 0000000000..694bf73664 --- /dev/null +++ b/packages/vscode-shim/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@roo-code/config-eslint/base" + +/** @type {import("eslint").Linter.Config} */ +export default [...config] diff --git a/packages/vscode-shim/package.json b/packages/vscode-shim/package.json new file mode 100644 index 0000000000..f657a6841f --- /dev/null +++ b/packages/vscode-shim/package.json @@ -0,0 +1,20 @@ +{ + "name": "@roo-code/vscode-shim", + "private": true, + "type": "module", + "exports": "./src/index.ts", + "scripts": { + "format": "prettier --write 'src/**/*.ts'", + "lint": "eslint src --ext .ts --max-warnings=0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "clean": "rimraf .turbo" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "^24.1.0", + "vitest": "^3.2.3" + }, + "dependencies": {} +} diff --git a/packages/vscode-shim/src/__tests__/Additional.test.ts b/packages/vscode-shim/src/__tests__/Additional.test.ts new file mode 100644 index 0000000000..7f1abbee14 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/Additional.test.ts @@ -0,0 +1,378 @@ +import { + Location, + DiagnosticRelatedInformation, + Diagnostic, + ThemeColor, + ThemeIcon, + CodeActionKind, + CodeLens, + LanguageModelTextPart, + LanguageModelToolCallPart, + LanguageModelToolResultPart, + FileSystemError, +} from "../classes/Additional.js" +import { Uri } from "../classes/Uri.js" +import { Range } from "../classes/Range.js" +import { Position } from "../classes/Position.js" + +describe("Location", () => { + it("should create location with URI and Range", () => { + const uri = Uri.file("/path/to/file.txt") + const range = new Range(0, 0, 5, 10) + const location = new Location(uri, range) + + expect(location.uri).toBe(uri) + expect(location.range).toBe(range) + }) + + it("should create location with URI and Position", () => { + const uri = Uri.file("/path/to/file.txt") + const position = new Position(5, 10) + const location = new Location(uri, position) + + expect(location.uri).toBe(uri) + expect(location.range).toBe(position) + }) +}) + +describe("DiagnosticRelatedInformation", () => { + it("should create diagnostic related information", () => { + const uri = Uri.file("/path/to/file.txt") + const range = new Range(0, 0, 1, 0) + const location = new Location(uri, range) + const message = "Related issue here" + + const info = new DiagnosticRelatedInformation(location, message) + + expect(info.location).toBe(location) + expect(info.message).toBe(message) + }) +}) + +describe("Diagnostic", () => { + it("should create diagnostic with default severity (Error)", () => { + const range = new Range(0, 0, 0, 10) + const message = "Error message" + + const diagnostic = new Diagnostic(range, message) + + expect(diagnostic.range.isEqual(range)).toBe(true) + expect(diagnostic.message).toBe(message) + expect(diagnostic.severity).toBe(0) // Error + }) + + it("should create diagnostic with custom severity", () => { + const range = new Range(0, 0, 0, 10) + const message = "Warning message" + + const diagnostic = new Diagnostic(range, message, 1) // Warning + + expect(diagnostic.severity).toBe(1) + }) + + it("should allow setting optional properties", () => { + const range = new Range(0, 0, 0, 10) + const diagnostic = new Diagnostic(range, "Test") + + diagnostic.source = "eslint" + diagnostic.code = "no-unused-vars" + diagnostic.tags = [1] // Unnecessary + + expect(diagnostic.source).toBe("eslint") + expect(diagnostic.code).toBe("no-unused-vars") + expect(diagnostic.tags).toEqual([1]) + }) + + it("should allow setting related information", () => { + const range = new Range(0, 0, 0, 10) + const diagnostic = new Diagnostic(range, "Test") + + const relatedUri = Uri.file("/related.txt") + const relatedLocation = new Location(relatedUri, new Range(1, 0, 1, 5)) + const relatedInfo = new DiagnosticRelatedInformation(relatedLocation, "Related issue") + + diagnostic.relatedInformation = [relatedInfo] + + expect(diagnostic.relatedInformation).toHaveLength(1) + expect(diagnostic.relatedInformation[0]?.message).toBe("Related issue") + }) +}) + +describe("ThemeColor", () => { + it("should create theme color with ID", () => { + const color = new ThemeColor("editor.foreground") + + expect(color.id).toBe("editor.foreground") + }) + + it("should handle custom color IDs", () => { + const color = new ThemeColor("myExtension.customColor") + + expect(color.id).toBe("myExtension.customColor") + }) +}) + +describe("ThemeIcon", () => { + it("should create theme icon with ID", () => { + const icon = new ThemeIcon("file") + + expect(icon.id).toBe("file") + expect(icon.color).toBeUndefined() + }) + + it("should create theme icon with ID and color", () => { + const color = new ThemeColor("errorForeground") + const icon = new ThemeIcon("error", color) + + expect(icon.id).toBe("error") + expect(icon.color).toBe(color) + expect(icon.color?.id).toBe("errorForeground") + }) +}) + +describe("CodeActionKind", () => { + describe("static properties", () => { + it("should have Empty kind", () => { + expect(CodeActionKind.Empty.value).toBe("") + }) + + it("should have QuickFix kind", () => { + expect(CodeActionKind.QuickFix.value).toBe("quickfix") + }) + + it("should have Refactor kind", () => { + expect(CodeActionKind.Refactor.value).toBe("refactor") + }) + + it("should have RefactorExtract kind", () => { + expect(CodeActionKind.RefactorExtract.value).toBe("refactor.extract") + }) + + it("should have RefactorInline kind", () => { + expect(CodeActionKind.RefactorInline.value).toBe("refactor.inline") + }) + + it("should have RefactorRewrite kind", () => { + expect(CodeActionKind.RefactorRewrite.value).toBe("refactor.rewrite") + }) + + it("should have Source kind", () => { + expect(CodeActionKind.Source.value).toBe("source") + }) + + it("should have SourceOrganizeImports kind", () => { + expect(CodeActionKind.SourceOrganizeImports.value).toBe("source.organizeImports") + }) + }) + + describe("constructor", () => { + it("should create custom kind", () => { + const kind = new CodeActionKind("custom.action") + expect(kind.value).toBe("custom.action") + }) + }) + + describe("append()", () => { + it("should append to existing kind", () => { + const kind = new CodeActionKind("refactor") + const appended = kind.append("extract") + + expect(appended.value).toBe("refactor.extract") + }) + + it("should handle empty kind", () => { + const kind = new CodeActionKind("") + const appended = kind.append("quickfix") + + expect(appended.value).toBe("quickfix") + }) + }) + + describe("contains()", () => { + it("should return true when kind contains another", () => { + const parent = CodeActionKind.Refactor + const child = CodeActionKind.RefactorExtract + + expect(parent.contains(child)).toBe(true) + }) + + it("should return false when kinds are different hierarchies", () => { + const quickfix = CodeActionKind.QuickFix + const refactor = CodeActionKind.Refactor + + expect(quickfix.contains(refactor)).toBe(false) + }) + + it("should return true for equal kinds", () => { + const kind = new CodeActionKind("quickfix") + expect(kind.contains(CodeActionKind.QuickFix)).toBe(true) + }) + }) + + describe("intersects()", () => { + it("should return true when one contains the other", () => { + const parent = CodeActionKind.Refactor + const child = CodeActionKind.RefactorExtract + + expect(parent.intersects(child)).toBe(true) + expect(child.intersects(parent)).toBe(true) + }) + + it("should return false for non-intersecting kinds", () => { + const quickfix = CodeActionKind.QuickFix + const source = CodeActionKind.Source + + expect(quickfix.intersects(source)).toBe(false) + }) + }) +}) + +describe("CodeLens", () => { + it("should create CodeLens with range only", () => { + const range = new Range(0, 0, 0, 10) + const lens = new CodeLens(range) + + expect(lens.range.isEqual(range)).toBe(true) + expect(lens.command).toBeUndefined() + expect(lens.isResolved).toBe(false) + }) + + it("should create CodeLens with range and command", () => { + const range = new Range(5, 0, 5, 20) + const command = { + command: "myExtension.doSomething", + title: "Click me", + arguments: [1, 2, 3], + } + const lens = new CodeLens(range, command) + + expect(lens.range).toBeDefined() + expect(lens.command?.command).toBe("myExtension.doSomething") + expect(lens.command?.title).toBe("Click me") + expect(lens.command?.arguments).toEqual([1, 2, 3]) + }) +}) + +describe("LanguageModelTextPart", () => { + it("should create text part with value", () => { + const part = new LanguageModelTextPart("Hello, world!") + + expect(part.value).toBe("Hello, world!") + }) +}) + +describe("LanguageModelToolCallPart", () => { + it("should create tool call part", () => { + const part = new LanguageModelToolCallPart("call-123", "searchFiles", { query: "test" }) + + expect(part.callId).toBe("call-123") + expect(part.name).toBe("searchFiles") + expect(part.input).toEqual({ query: "test" }) + }) +}) + +describe("LanguageModelToolResultPart", () => { + it("should create tool result part", () => { + const part = new LanguageModelToolResultPart("call-123", [{ type: "text", text: "result" }]) + + expect(part.callId).toBe("call-123") + expect(part.content).toHaveLength(1) + expect(part.content[0]).toEqual({ type: "text", text: "result" }) + }) +}) + +describe("FileSystemError", () => { + describe("constructor", () => { + it("should create error with message", () => { + const error = new FileSystemError("Something went wrong") + + expect(error.message).toBe("Something went wrong") + expect(error.code).toBe("Unknown") + expect(error.name).toBe("FileSystemError") + }) + + it("should create error with message and code", () => { + const error = new FileSystemError("Custom error", "CustomCode") + + expect(error.message).toBe("Custom error") + expect(error.code).toBe("CustomCode") + }) + }) + + describe("FileNotFound()", () => { + it("should create FileNotFound error from string", () => { + const error = FileSystemError.FileNotFound("File not found: /path/to/file") + + expect(error.message).toBe("File not found: /path/to/file") + expect(error.code).toBe("FileNotFound") + }) + + it("should create FileNotFound error from URI", () => { + const uri = Uri.file("/path/to/file.txt") + const error = FileSystemError.FileNotFound(uri) + + expect(error.message).toContain("/path/to/file.txt") + expect(error.code).toBe("FileNotFound") + }) + + it("should handle undefined input", () => { + const error = FileSystemError.FileNotFound() + + expect(error.message).toContain("unknown") + expect(error.code).toBe("FileNotFound") + }) + }) + + describe("FileExists()", () => { + it("should create FileExists error", () => { + const error = FileSystemError.FileExists("File already exists") + + expect(error.message).toBe("File already exists") + expect(error.code).toBe("FileExists") + }) + + it("should create FileExists error from URI", () => { + const uri = Uri.file("/existing/file.txt") + const error = FileSystemError.FileExists(uri) + + expect(error.message).toContain("/existing/file.txt") + expect(error.code).toBe("FileExists") + }) + }) + + describe("FileNotADirectory()", () => { + it("should create FileNotADirectory error", () => { + const error = FileSystemError.FileNotADirectory("Not a directory") + + expect(error.message).toBe("Not a directory") + expect(error.code).toBe("FileNotADirectory") + }) + }) + + describe("FileIsADirectory()", () => { + it("should create FileIsADirectory error", () => { + const error = FileSystemError.FileIsADirectory("Is a directory") + + expect(error.message).toBe("Is a directory") + expect(error.code).toBe("FileIsADirectory") + }) + }) + + describe("NoPermissions()", () => { + it("should create NoPermissions error", () => { + const error = FileSystemError.NoPermissions("Access denied") + + expect(error.message).toBe("Access denied") + expect(error.code).toBe("NoPermissions") + }) + }) + + describe("Unavailable()", () => { + it("should create Unavailable error", () => { + const error = FileSystemError.Unavailable("Resource unavailable") + + expect(error.message).toBe("Resource unavailable") + expect(error.code).toBe("Unavailable") + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/CancellationToken.test.ts b/packages/vscode-shim/src/__tests__/CancellationToken.test.ts new file mode 100644 index 0000000000..819b38dfa0 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/CancellationToken.test.ts @@ -0,0 +1,156 @@ +import { CancellationTokenSource } from "../classes/CancellationToken.js" + +describe("CancellationToken", () => { + describe("initial state", () => { + it("should not be cancelled initially", () => { + const source = new CancellationTokenSource() + const token = source.token + + expect(token.isCancellationRequested).toBe(false) + }) + + it("should have onCancellationRequested function", () => { + const source = new CancellationTokenSource() + const token = source.token + + expect(typeof token.onCancellationRequested).toBe("function") + }) + }) +}) + +describe("CancellationTokenSource", () => { + describe("token property", () => { + it("should return a CancellationToken", () => { + const source = new CancellationTokenSource() + const token = source.token + + expect(token).toBeDefined() + expect(typeof token.isCancellationRequested).toBe("boolean") + expect(typeof token.onCancellationRequested).toBe("function") + }) + + it("should return the same token instance on multiple accesses", () => { + const source = new CancellationTokenSource() + + expect(source.token).toBe(source.token) + }) + }) + + describe("cancel()", () => { + it("should set isCancellationRequested to true", () => { + const source = new CancellationTokenSource() + + source.cancel() + + expect(source.token.isCancellationRequested).toBe(true) + }) + + it("should fire onCancellationRequested event", () => { + const source = new CancellationTokenSource() + const listener = vi.fn() + + source.token.onCancellationRequested(listener) + source.cancel() + + expect(listener).toHaveBeenCalledTimes(1) + }) + + it("should only fire event once on multiple cancel calls", () => { + const source = new CancellationTokenSource() + const listener = vi.fn() + + source.token.onCancellationRequested(listener) + source.cancel() + source.cancel() + source.cancel() + + expect(listener).toHaveBeenCalledTimes(1) + }) + + it("should be idempotent", () => { + const source = new CancellationTokenSource() + + source.cancel() + source.cancel() + + expect(source.token.isCancellationRequested).toBe(true) + }) + }) + + describe("dispose()", () => { + it("should cancel the token", () => { + const source = new CancellationTokenSource() + + source.dispose() + + expect(source.token.isCancellationRequested).toBe(true) + }) + + it("should fire onCancellationRequested event", () => { + const source = new CancellationTokenSource() + const listener = vi.fn() + + source.token.onCancellationRequested(listener) + source.dispose() + + expect(listener).toHaveBeenCalledTimes(1) + }) + + it("should be safe to call multiple times", () => { + const source = new CancellationTokenSource() + + expect(() => { + source.dispose() + source.dispose() + }).not.toThrow() + }) + }) + + describe("onCancellationRequested", () => { + it("should return a disposable", () => { + const source = new CancellationTokenSource() + const listener = vi.fn() + + const disposable = source.token.onCancellationRequested(listener) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + + it("should stop listening after disposing", () => { + const source = new CancellationTokenSource() + const listener = vi.fn() + + const disposable = source.token.onCancellationRequested(listener) + disposable.dispose() + source.cancel() + + expect(listener).not.toHaveBeenCalled() + }) + + it("should call listener immediately if already cancelled", () => { + const source = new CancellationTokenSource() + source.cancel() + + const listener = vi.fn() + source.token.onCancellationRequested(listener) + + // Event was already fired, listener added after won't be called + // This matches VSCode behavior + expect(listener).not.toHaveBeenCalled() + }) + + it("should support multiple listeners", () => { + const source = new CancellationTokenSource() + const listener1 = vi.fn() + const listener2 = vi.fn() + + source.token.onCancellationRequested(listener1) + source.token.onCancellationRequested(listener2) + source.cancel() + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/CommandsAPI.test.ts b/packages/vscode-shim/src/__tests__/CommandsAPI.test.ts new file mode 100644 index 0000000000..251b9c9d29 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/CommandsAPI.test.ts @@ -0,0 +1,157 @@ +import { CommandsAPI } from "../api/CommandsAPI.js" + +describe("CommandsAPI", () => { + let commands: CommandsAPI + + beforeEach(() => { + commands = new CommandsAPI() + }) + + describe("registerCommand()", () => { + it("should register a command", () => { + const callback = vi.fn() + + commands.registerCommand("test.command", callback) + commands.executeCommand("test.command") + + expect(callback).toHaveBeenCalled() + }) + + it("should return a disposable", () => { + const callback = vi.fn() + + const disposable = commands.registerCommand("test.command", callback) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + + it("should unregister command on dispose", async () => { + const callback = vi.fn() + + const disposable = commands.registerCommand("test.command", callback) + disposable.dispose() + await commands.executeCommand("test.command") + + expect(callback).not.toHaveBeenCalled() + }) + + it("should allow registering multiple commands", () => { + const callback1 = vi.fn() + const callback2 = vi.fn() + + commands.registerCommand("test.command1", callback1) + commands.registerCommand("test.command2", callback2) + + commands.executeCommand("test.command1") + commands.executeCommand("test.command2") + + expect(callback1).toHaveBeenCalled() + expect(callback2).toHaveBeenCalled() + }) + }) + + describe("executeCommand()", () => { + it("should execute registered command", async () => { + const callback = vi.fn().mockReturnValue("result") + + commands.registerCommand("test.command", callback) + const result = await commands.executeCommand("test.command") + + expect(result).toBe("result") + }) + + it("should pass arguments to command handler", async () => { + const callback = vi.fn() + + commands.registerCommand("test.command", callback) + await commands.executeCommand("test.command", "arg1", "arg2", 123) + + expect(callback).toHaveBeenCalledWith("arg1", "arg2", 123) + }) + + it("should return promise for unknown command", () => { + const result = commands.executeCommand("unknown.command") + + expect(result).toBeInstanceOf(Promise) + }) + + it("should resolve to undefined for unknown command", async () => { + const result = await commands.executeCommand("unknown.command") + + expect(result).toBeUndefined() + }) + + it("should reject if handler throws", async () => { + commands.registerCommand("test.error", () => { + throw new Error("Test error") + }) + + await expect(commands.executeCommand("test.error")).rejects.toThrow("Test error") + }) + + it("should handle async command handlers", async () => { + commands.registerCommand("test.async", async () => { + return "async result" + }) + + const result = await commands.executeCommand("test.async") + + expect(result).toBe("async result") + }) + }) + + describe("built-in commands", () => { + it("should handle workbench.action.files.saveFiles", async () => { + const result = await commands.executeCommand("workbench.action.files.saveFiles") + + expect(result).toBeUndefined() + }) + + it("should handle workbench.action.closeWindow", async () => { + const result = await commands.executeCommand("workbench.action.closeWindow") + + expect(result).toBeUndefined() + }) + + it("should handle workbench.action.reloadWindow", async () => { + const result = await commands.executeCommand("workbench.action.reloadWindow") + + expect(result).toBeUndefined() + }) + }) + + describe("generic type support", () => { + it("should support typed return values", async () => { + commands.registerCommand("test.typed", () => 42) + + const result = await commands.executeCommand("test.typed") + + expect(result).toBe(42) + }) + + it("should support complex return types", async () => { + const expected = { name: "test", value: 123 } + commands.registerCommand("test.object", () => expected) + + const result = await commands.executeCommand<{ name: string; value: number }>("test.object") + + expect(result).toEqual(expected) + }) + }) + + describe("command overwriting", () => { + it("should allow registering same command multiple times", () => { + const callback1 = vi.fn().mockReturnValue(1) + const callback2 = vi.fn().mockReturnValue(2) + + commands.registerCommand("test.command", callback1) + commands.registerCommand("test.command", callback2) + + // Last registration wins + const result = commands.executeCommand("test.command") + + expect(result).resolves.toBe(2) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/EventEmitter.test.ts b/packages/vscode-shim/src/__tests__/EventEmitter.test.ts new file mode 100644 index 0000000000..5a5e4b976f --- /dev/null +++ b/packages/vscode-shim/src/__tests__/EventEmitter.test.ts @@ -0,0 +1,133 @@ +import { EventEmitter } from "../classes/EventEmitter.js" + +describe("EventEmitter", () => { + describe("event subscription", () => { + it("should subscribe and receive events", () => { + const emitter = new EventEmitter() + const listener = vi.fn() + + emitter.event(listener) + emitter.fire("test") + + expect(listener).toHaveBeenCalledWith("test") + expect(listener).toHaveBeenCalledTimes(1) + }) + + it("should support multiple listeners", () => { + const emitter = new EventEmitter() + const listener1 = vi.fn() + const listener2 = vi.fn() + + emitter.event(listener1) + emitter.event(listener2) + emitter.fire(42) + + expect(listener1).toHaveBeenCalledWith(42) + expect(listener2).toHaveBeenCalledWith(42) + }) + + it("should bind thisArgs when provided", () => { + const emitter = new EventEmitter() + const context = { name: "test", capturedThis: null as unknown } + + emitter.event(function (this: typeof context) { + this.capturedThis = this + }, context) + + emitter.fire("event") + expect(context.capturedThis).toBe(context) + }) + + it("should add disposable to array when provided", () => { + const emitter = new EventEmitter() + const disposables: { dispose: () => void }[] = [] + + emitter.event(() => {}, undefined, disposables) + + expect(disposables).toHaveLength(1) + expect(typeof disposables[0]?.dispose).toBe("function") + }) + }) + + describe("dispose subscription", () => { + it("should stop receiving events after dispose", () => { + const emitter = new EventEmitter() + const listener = vi.fn() + + const disposable = emitter.event(listener) + emitter.fire("before") + + disposable.dispose() + emitter.fire("after") + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith("before") + }) + }) + + describe("dispose emitter", () => { + it("should remove all listeners on dispose", () => { + const emitter = new EventEmitter() + const listener1 = vi.fn() + const listener2 = vi.fn() + + emitter.event(listener1) + emitter.event(listener2) + + emitter.dispose() + emitter.fire("test") + + expect(listener1).not.toHaveBeenCalled() + expect(listener2).not.toHaveBeenCalled() + }) + + it("should have zero listeners after dispose", () => { + const emitter = new EventEmitter() + emitter.event(() => {}) + emitter.event(() => {}) + + expect(emitter.listenerCount).toBe(2) + + emitter.dispose() + expect(emitter.listenerCount).toBe(0) + }) + }) + + describe("error handling", () => { + it("should not fail if a listener throws", () => { + const emitter = new EventEmitter() + const goodListener = vi.fn() + + emitter.event(() => { + throw new Error("Listener error") + }) + emitter.event(goodListener) + + // Should not throw + expect(() => emitter.fire("test")).not.toThrow() + + // Good listener should still be called + expect(goodListener).toHaveBeenCalledWith("test") + }) + }) + + describe("listenerCount", () => { + it("should track number of listeners", () => { + const emitter = new EventEmitter() + + expect(emitter.listenerCount).toBe(0) + + const d1 = emitter.event(() => {}) + expect(emitter.listenerCount).toBe(1) + + const d2 = emitter.event(() => {}) + expect(emitter.listenerCount).toBe(2) + + d1.dispose() + expect(emitter.listenerCount).toBe(1) + + d2.dispose() + expect(emitter.listenerCount).toBe(0) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/ExtensionContext.test.ts b/packages/vscode-shim/src/__tests__/ExtensionContext.test.ts new file mode 100644 index 0000000000..beb71d7deb --- /dev/null +++ b/packages/vscode-shim/src/__tests__/ExtensionContext.test.ts @@ -0,0 +1,343 @@ +import { ExtensionContextImpl } from "../context/ExtensionContext.js" +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +describe("ExtensionContextImpl", () => { + let tempDir: string + let extensionPath: string + let workspacePath: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "ext-context-test-")) + extensionPath = path.join(tempDir, "extension") + workspacePath = path.join(tempDir, "workspace") + fs.mkdirSync(extensionPath, { recursive: true }) + fs.mkdirSync(workspacePath, { recursive: true }) + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + describe("constructor", () => { + it("should create context with extension path", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + expect(context.extensionPath).toBe(extensionPath) + expect(context.extensionUri.fsPath).toBe(extensionPath) + }) + + it("should use default extension mode (Production)", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + expect(context.extensionMode).toBe(1) // Production + }) + + it("should allow custom extension mode", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + extensionMode: 2, // Development + }) + + expect(context.extensionMode).toBe(2) + }) + + it("should initialize empty subscriptions array", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + expect(context.subscriptions).toEqual([]) + }) + + it("should initialize environmentVariableCollection", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + expect(context.environmentVariableCollection).toEqual({}) + }) + }) + + describe("storage paths", () => { + it("should set up global storage path", () => { + const customStorageDir = path.join(tempDir, "custom-storage") + + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: customStorageDir, + }) + + expect(context.globalStoragePath).toContain("global-storage") + expect(context.globalStorageUri.fsPath).toBe(context.globalStoragePath) + }) + + it("should set up workspace storage path with hash", () => { + const customStorageDir = path.join(tempDir, "custom-storage") + + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: customStorageDir, + }) + + expect(context.storagePath).toContain("workspace-storage") + expect(context.storageUri?.fsPath).toBe(context.storagePath) + }) + + it("should set up log path", () => { + const customStorageDir = path.join(tempDir, "custom-storage") + + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: customStorageDir, + }) + + expect(context.logPath).toContain("logs") + expect(context.logUri.fsPath).toBe(context.logPath) + }) + + it("should create storage directories", () => { + const customStorageDir = path.join(tempDir, "custom-storage") + + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: customStorageDir, + }) + + expect(fs.existsSync(context.globalStoragePath)).toBe(true) + expect(fs.existsSync(context.storagePath!)).toBe(true) + expect(fs.existsSync(context.logPath)).toBe(true) + }) + + it("should generate different workspace hashes for different paths", () => { + const workspace1 = path.join(tempDir, "workspace1") + const workspace2 = path.join(tempDir, "workspace2") + fs.mkdirSync(workspace1, { recursive: true }) + fs.mkdirSync(workspace2, { recursive: true }) + + const context1 = new ExtensionContextImpl({ + extensionPath, + workspacePath: workspace1, + storageDir: path.join(tempDir, "storage1"), + }) + + const context2 = new ExtensionContextImpl({ + extensionPath, + workspacePath: workspace2, + storageDir: path.join(tempDir, "storage2"), + }) + + // The hashes should be different + const hash1 = path.basename(context1.storagePath!) + const hash2 = path.basename(context2.storagePath!) + expect(hash1).not.toBe(hash2) + }) + }) + + describe("workspaceState", () => { + it("should provide workspaceState memento", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + + expect(context.workspaceState).toBeDefined() + expect(typeof context.workspaceState.get).toBe("function") + expect(typeof context.workspaceState.update).toBe("function") + expect(typeof context.workspaceState.keys).toBe("function") + }) + + it("should persist workspace state", async () => { + const storageDir = path.join(tempDir, "storage") + + const context1 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + await context1.workspaceState.update("testKey", "testValue") + + // Create new context with same storage + const context2 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + expect(context2.workspaceState.get("testKey")).toBe("testValue") + }) + }) + + describe("globalState", () => { + it("should provide globalState memento", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + + expect(context.globalState).toBeDefined() + expect(typeof context.globalState.get).toBe("function") + expect(typeof context.globalState.update).toBe("function") + expect(typeof context.globalState.keys).toBe("function") + }) + + it("should have setKeysForSync method", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + + expect(typeof context.globalState.setKeysForSync).toBe("function") + // Should not throw + expect(() => context.globalState.setKeysForSync(["key1", "key2"])).not.toThrow() + }) + + it("should persist global state", async () => { + const storageDir = path.join(tempDir, "storage") + + const context1 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + await context1.globalState.update("globalKey", "globalValue") + + // Create new context with same storage + const context2 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + expect(context2.globalState.get("globalKey")).toBe("globalValue") + }) + }) + + describe("secrets", () => { + it("should provide secrets storage", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + + expect(context.secrets).toBeDefined() + expect(typeof context.secrets.get).toBe("function") + expect(typeof context.secrets.store).toBe("function") + expect(typeof context.secrets.delete).toBe("function") + }) + + it("should persist secrets", async () => { + const storageDir = path.join(tempDir, "storage") + + const context1 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + await context1.secrets.store("apiKey", "secret123") + + // Create new context with same storage + const context2 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + const secret = await context2.secrets.get("apiKey") + expect(secret).toBe("secret123") + }) + }) + + describe("dispose()", () => { + it("should dispose all subscriptions", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + const disposable1 = { dispose: vi.fn() } + const disposable2 = { dispose: vi.fn() } + + context.subscriptions.push(disposable1) + context.subscriptions.push(disposable2) + + context.dispose() + + expect(disposable1.dispose).toHaveBeenCalledTimes(1) + expect(disposable2.dispose).toHaveBeenCalledTimes(1) + }) + + it("should clear subscriptions array after dispose", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + context.subscriptions.push({ dispose: () => {} }) + context.subscriptions.push({ dispose: () => {} }) + + context.dispose() + + expect(context.subscriptions).toEqual([]) + }) + + it("should handle disposal errors gracefully", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + // Add a disposable that throws + context.subscriptions.push({ + dispose: () => { + throw new Error("Disposal error") + }, + }) + + // Add a normal disposable + const normalDisposable = { dispose: vi.fn() } + context.subscriptions.push(normalDisposable) + + // Should not throw + expect(() => context.dispose()).not.toThrow() + + // Normal disposable should still be called + expect(normalDisposable.dispose).toHaveBeenCalled() + }) + }) + + describe("default storage directory", () => { + it("should use home directory based default when no storageDir provided", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + // Should contain .vscode-mock in the path + expect(context.globalStoragePath).toContain(".vscode-mock") + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/FileSystemAPI.test.ts b/packages/vscode-shim/src/__tests__/FileSystemAPI.test.ts new file mode 100644 index 0000000000..1b7e0e012c --- /dev/null +++ b/packages/vscode-shim/src/__tests__/FileSystemAPI.test.ts @@ -0,0 +1,129 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { FileSystemAPI } from "../api/FileSystemAPI.js" +import { Uri } from "../classes/Uri.js" + +describe("FileSystemAPI", () => { + let tempDir: string + let fsAPI: FileSystemAPI + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "fs-api-test-")) + fsAPI = new FileSystemAPI() + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + describe("stat()", () => { + it("should stat a file", async () => { + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "test content") + + const uri = Uri.file(filePath) + const stat = await fsAPI.stat(uri) + + expect(stat.type).toBe(1) // File + expect(stat.size).toBeGreaterThan(0) + expect(stat.mtime).toBeGreaterThan(0) + expect(stat.ctime).toBeGreaterThan(0) + }) + + it("should stat a directory", async () => { + const uri = Uri.file(tempDir) + const stat = await fsAPI.stat(uri) + + expect(stat.type).toBe(2) // Directory + }) + + it("should return default stat for non-existent file", async () => { + const uri = Uri.file(path.join(tempDir, "nonexistent.txt")) + const stat = await fsAPI.stat(uri) + + expect(stat.type).toBe(1) // File (default) + expect(stat.size).toBe(0) + }) + }) + + describe("readFile()", () => { + it("should read file content", async () => { + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "Hello, world!") + + const uri = Uri.file(filePath) + const content = await fsAPI.readFile(uri) + + expect(Buffer.from(content).toString()).toBe("Hello, world!") + }) + + it("should throw FileSystemError for non-existent file", async () => { + const uri = Uri.file(path.join(tempDir, "nonexistent.txt")) + + await expect(fsAPI.readFile(uri)).rejects.toThrow() + }) + }) + + describe("writeFile()", () => { + it("should write file content", async () => { + const filePath = path.join(tempDir, "output.txt") + const uri = Uri.file(filePath) + + await fsAPI.writeFile(uri, new TextEncoder().encode("Written content")) + + expect(fs.readFileSync(filePath, "utf-8")).toBe("Written content") + }) + + it("should create parent directories if they don't exist", async () => { + const filePath = path.join(tempDir, "subdir", "nested", "file.txt") + const uri = Uri.file(filePath) + + await fsAPI.writeFile(uri, new TextEncoder().encode("Nested content")) + + expect(fs.readFileSync(filePath, "utf-8")).toBe("Nested content") + }) + }) + + describe("delete()", () => { + it("should delete a file", async () => { + const filePath = path.join(tempDir, "to-delete.txt") + fs.writeFileSync(filePath, "delete me") + + const uri = Uri.file(filePath) + await fsAPI.delete(uri) + + expect(fs.existsSync(filePath)).toBe(false) + }) + + it("should throw error for non-existent file", async () => { + const uri = Uri.file(path.join(tempDir, "nonexistent.txt")) + + await expect(fsAPI.delete(uri)).rejects.toThrow() + }) + }) + + describe("createDirectory()", () => { + it("should create a directory", async () => { + const dirPath = path.join(tempDir, "new-dir") + const uri = Uri.file(dirPath) + + await fsAPI.createDirectory(uri) + + expect(fs.existsSync(dirPath)).toBe(true) + expect(fs.statSync(dirPath).isDirectory()).toBe(true) + }) + + it("should create nested directories", async () => { + const dirPath = path.join(tempDir, "a", "b", "c") + const uri = Uri.file(dirPath) + + await fsAPI.createDirectory(uri) + + expect(fs.existsSync(dirPath)).toBe(true) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/OutputChannel.test.ts b/packages/vscode-shim/src/__tests__/OutputChannel.test.ts new file mode 100644 index 0000000000..043e712d5b --- /dev/null +++ b/packages/vscode-shim/src/__tests__/OutputChannel.test.ts @@ -0,0 +1,117 @@ +import { OutputChannel } from "../classes/OutputChannel.js" +import { setLogger } from "../utils/logger.js" + +describe("OutputChannel", () => { + let mockLogger: { + debug: ReturnType + info: ReturnType + warn: ReturnType + error: ReturnType + } + + beforeEach(() => { + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + setLogger(mockLogger) + }) + + describe("constructor", () => { + it("should create an output channel with the given name", () => { + const channel = new OutputChannel("TestChannel") + + expect(channel.name).toBe("TestChannel") + }) + }) + + describe("name property", () => { + it("should return the channel name", () => { + const channel = new OutputChannel("MyChannel") + + expect(channel.name).toBe("MyChannel") + }) + }) + + describe("append()", () => { + it("should log the value with channel name prefix", () => { + const channel = new OutputChannel("TestChannel") + + channel.append("test message") + + expect(mockLogger.info).toHaveBeenCalledWith( + "[TestChannel] test message", + "VSCode.OutputChannel", + undefined, + ) + }) + + it("should handle empty strings", () => { + const channel = new OutputChannel("TestChannel") + + channel.append("") + + expect(mockLogger.info).toHaveBeenCalledWith("[TestChannel] ", "VSCode.OutputChannel", undefined) + }) + }) + + describe("appendLine()", () => { + it("should log the value with channel name prefix", () => { + const channel = new OutputChannel("TestChannel") + + channel.appendLine("line message") + + expect(mockLogger.info).toHaveBeenCalledWith( + "[TestChannel] line message", + "VSCode.OutputChannel", + undefined, + ) + }) + + it("should handle multi-line strings", () => { + const channel = new OutputChannel("TestChannel") + + channel.appendLine("line1\nline2") + + expect(mockLogger.info).toHaveBeenCalledWith( + "[TestChannel] line1\nline2", + "VSCode.OutputChannel", + undefined, + ) + }) + }) + + describe("clear()", () => { + it("should not throw when called", () => { + const channel = new OutputChannel("TestChannel") + + expect(() => channel.clear()).not.toThrow() + }) + }) + + describe("show()", () => { + it("should not throw when called without arguments", () => { + const channel = new OutputChannel("TestChannel") + + expect(() => channel.show()).not.toThrow() + }) + }) + + describe("hide()", () => { + it("should not throw when called", () => { + const channel = new OutputChannel("TestChannel") + + expect(() => channel.hide()).not.toThrow() + }) + }) + + describe("dispose()", () => { + it("should not throw when called", () => { + const channel = new OutputChannel("TestChannel") + + expect(() => channel.dispose()).not.toThrow() + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/Position.test.ts b/packages/vscode-shim/src/__tests__/Position.test.ts new file mode 100644 index 0000000000..4b417b4003 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/Position.test.ts @@ -0,0 +1,139 @@ +import { Position } from "../classes/Position.js" + +describe("Position", () => { + describe("constructor", () => { + it("should create a position with line and character", () => { + const pos = new Position(5, 10) + expect(pos.line).toBe(5) + expect(pos.character).toBe(10) + }) + + it("should reject negative line numbers", () => { + expect(() => new Position(-1, 0)).toThrow("Line number must be non-negative") + }) + + it("should reject negative character offsets", () => { + expect(() => new Position(0, -1)).toThrow("Character offset must be non-negative") + }) + }) + + describe("isEqual()", () => { + it("should return true for equal positions", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(5, 10) + expect(pos1.isEqual(pos2)).toBe(true) + }) + + it("should return false for different positions", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(5, 11) + expect(pos1.isEqual(pos2)).toBe(false) + }) + }) + + describe("isBefore()", () => { + it("should return true when line is before", () => { + const pos1 = new Position(3, 10) + const pos2 = new Position(5, 5) + expect(pos1.isBefore(pos2)).toBe(true) + }) + + it("should return true when same line but character before", () => { + const pos1 = new Position(5, 8) + const pos2 = new Position(5, 10) + expect(pos1.isBefore(pos2)).toBe(true) + }) + + it("should return false when equal", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(5, 10) + expect(pos1.isBefore(pos2)).toBe(false) + }) + + it("should return false when after", () => { + const pos1 = new Position(6, 0) + const pos2 = new Position(5, 10) + expect(pos1.isBefore(pos2)).toBe(false) + }) + }) + + describe("isAfter()", () => { + it("should return true when line is after", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(3, 10) + expect(pos1.isAfter(pos2)).toBe(true) + }) + + it("should return false when equal", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(5, 10) + expect(pos1.isAfter(pos2)).toBe(false) + }) + }) + + describe("compareTo()", () => { + it("should return -1 when before", () => { + const pos1 = new Position(3, 10) + const pos2 = new Position(5, 10) + expect(pos1.compareTo(pos2)).toBe(-1) + }) + + it("should return 0 when equal", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(5, 10) + expect(pos1.compareTo(pos2)).toBe(0) + }) + + it("should return 1 when after", () => { + const pos1 = new Position(7, 10) + const pos2 = new Position(5, 10) + expect(pos1.compareTo(pos2)).toBe(1) + }) + }) + + describe("translate()", () => { + it("should translate by delta values", () => { + const pos = new Position(5, 10) + const translated = pos.translate(2, 3) + expect(translated.line).toBe(7) + expect(translated.character).toBe(13) + }) + + it("should translate by change object", () => { + const pos = new Position(5, 10) + const translated = pos.translate({ lineDelta: 1, characterDelta: -2 }) + expect(translated.line).toBe(6) + expect(translated.character).toBe(8) + }) + + it("should handle omitted deltas as zero", () => { + const pos = new Position(5, 10) + const translated = pos.translate() + expect(translated.line).toBe(5) + expect(translated.character).toBe(10) + }) + }) + + describe("with()", () => { + it("should create new position with changed line", () => { + const pos = new Position(5, 10) + const modified = pos.with(8) + expect(modified.line).toBe(8) + expect(modified.character).toBe(10) + }) + + it("should create new position with change object", () => { + const pos = new Position(5, 10) + const modified = pos.with({ line: 8, character: 15 }) + expect(modified.line).toBe(8) + expect(modified.character).toBe(15) + }) + + it("should preserve unchanged properties", () => { + const pos = new Position(5, 10) + const modified = pos.with({ line: 8 }) + expect(modified.line).toBe(8) + expect(modified.character).toBe(10) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/Range.test.ts b/packages/vscode-shim/src/__tests__/Range.test.ts new file mode 100644 index 0000000000..5e85b02b83 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/Range.test.ts @@ -0,0 +1,153 @@ +import { Range } from "../classes/Range.js" +import { Position } from "../classes/Position.js" + +describe("Range", () => { + describe("constructor", () => { + it("should create range from Position objects", () => { + const start = new Position(0, 0) + const end = new Position(5, 10) + const range = new Range(start, end) + + expect(range.start.line).toBe(0) + expect(range.start.character).toBe(0) + expect(range.end.line).toBe(5) + expect(range.end.character).toBe(10) + }) + + it("should create range from numbers", () => { + const range = new Range(0, 0, 5, 10) + + expect(range.start.line).toBe(0) + expect(range.start.character).toBe(0) + expect(range.end.line).toBe(5) + expect(range.end.character).toBe(10) + }) + }) + + describe("isEmpty", () => { + it("should return true for empty range", () => { + const range = new Range(5, 10, 5, 10) + expect(range.isEmpty).toBe(true) + }) + + it("should return false for non-empty range", () => { + const range = new Range(5, 10, 5, 15) + expect(range.isEmpty).toBe(false) + }) + }) + + describe("isSingleLine", () => { + it("should return true for single line range", () => { + const range = new Range(5, 0, 5, 10) + expect(range.isSingleLine).toBe(true) + }) + + it("should return false for multi-line range", () => { + const range = new Range(5, 0, 6, 10) + expect(range.isSingleLine).toBe(false) + }) + }) + + describe("contains()", () => { + it("should return true when range contains position", () => { + const range = new Range(0, 0, 10, 10) + const pos = new Position(5, 5) + expect(range.contains(pos)).toBe(true) + }) + + it("should return false when position is outside range", () => { + const range = new Range(0, 0, 10, 10) + const pos = new Position(15, 5) + expect(range.contains(pos)).toBe(false) + }) + + it("should return true when range contains another range", () => { + const outer = new Range(0, 0, 10, 10) + const inner = new Range(2, 2, 8, 8) + expect(outer.contains(inner)).toBe(true) + }) + + it("should return false when range does not contain another range", () => { + const range1 = new Range(0, 0, 5, 10) + const range2 = new Range(6, 0, 10, 10) + expect(range1.contains(range2)).toBe(false) + }) + }) + + describe("isEqual()", () => { + it("should return true for equal ranges", () => { + const range1 = new Range(0, 0, 5, 10) + const range2 = new Range(0, 0, 5, 10) + expect(range1.isEqual(range2)).toBe(true) + }) + + it("should return false for different ranges", () => { + const range1 = new Range(0, 0, 5, 10) + const range2 = new Range(0, 0, 5, 11) + expect(range1.isEqual(range2)).toBe(false) + }) + }) + + describe("intersection()", () => { + it("should return intersection of overlapping ranges", () => { + const range1 = new Range(0, 0, 10, 10) + const range2 = new Range(5, 5, 15, 15) + const intersection = range1.intersection(range2) + + expect(intersection).toBeDefined() + expect(intersection!.start.line).toBe(5) + expect(intersection!.start.character).toBe(5) + expect(intersection!.end.line).toBe(10) + expect(intersection!.end.character).toBe(10) + }) + + it("should return undefined for non-overlapping ranges", () => { + const range1 = new Range(0, 0, 5, 10) + const range2 = new Range(10, 0, 15, 10) + const intersection = range1.intersection(range2) + + expect(intersection).toBeUndefined() + }) + }) + + describe("union()", () => { + it("should return union of two ranges", () => { + const range1 = new Range(0, 0, 5, 10) + const range2 = new Range(3, 5, 8, 15) + const union = range1.union(range2) + + expect(union.start.line).toBe(0) + expect(union.start.character).toBe(0) + expect(union.end.line).toBe(8) + expect(union.end.character).toBe(15) + }) + + it("should handle non-overlapping ranges", () => { + const range1 = new Range(0, 0, 2, 10) + const range2 = new Range(5, 0, 8, 10) + const union = range1.union(range2) + + expect(union.start.line).toBe(0) + expect(union.end.line).toBe(8) + }) + }) + + describe("with()", () => { + it("should create new range with modified start", () => { + const range = new Range(0, 0, 5, 10) + const modified = range.with(new Position(1, 0)) + + expect(modified.start.line).toBe(1) + expect(modified.end.line).toBe(5) + }) + + it("should create new range with change object", () => { + const range = new Range(0, 0, 5, 10) + const modified = range.with({ end: new Position(8, 15) }) + + expect(modified.start.line).toBe(0) + expect(modified.end.line).toBe(8) + expect(modified.end.character).toBe(15) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/Selection.test.ts b/packages/vscode-shim/src/__tests__/Selection.test.ts new file mode 100644 index 0000000000..208faf0df4 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/Selection.test.ts @@ -0,0 +1,123 @@ +import { Selection } from "../classes/Selection.js" +import { Position } from "../classes/Position.js" + +describe("Selection", () => { + describe("constructor with Position objects", () => { + it("should create selection from Position objects", () => { + const anchor = new Position(0, 0) + const active = new Position(5, 10) + const selection = new Selection(anchor, active) + + expect(selection.anchor.line).toBe(0) + expect(selection.anchor.character).toBe(0) + expect(selection.active.line).toBe(5) + expect(selection.active.character).toBe(10) + }) + + it("should set start and end correctly for non-reversed selection", () => { + const anchor = new Position(0, 0) + const active = new Position(5, 10) + const selection = new Selection(anchor, active) + + expect(selection.start.line).toBe(0) + expect(selection.start.character).toBe(0) + expect(selection.end.line).toBe(5) + expect(selection.end.character).toBe(10) + }) + + it("should set start and end correctly for reversed selection", () => { + const anchor = new Position(5, 10) + const active = new Position(0, 0) + const selection = new Selection(anchor, active) + + // Start/end are inherited from Range, which normalizes + expect(selection.anchor.line).toBe(5) + expect(selection.anchor.character).toBe(10) + expect(selection.active.line).toBe(0) + expect(selection.active.character).toBe(0) + }) + }) + + describe("constructor with line/character numbers", () => { + it("should create selection from line and character numbers", () => { + const selection = new Selection(0, 0, 5, 10) + + expect(selection.anchor.line).toBe(0) + expect(selection.anchor.character).toBe(0) + expect(selection.active.line).toBe(5) + expect(selection.active.character).toBe(10) + }) + + it("should handle reversed selection with numbers", () => { + const selection = new Selection(5, 10, 0, 0) + + expect(selection.anchor.line).toBe(5) + expect(selection.anchor.character).toBe(10) + expect(selection.active.line).toBe(0) + expect(selection.active.character).toBe(0) + }) + }) + + describe("isReversed", () => { + it("should return false when active is after anchor", () => { + const selection = new Selection(0, 0, 5, 10) + expect(selection.isReversed).toBe(false) + }) + + it("should return true when active is before anchor", () => { + const selection = new Selection(5, 10, 0, 0) + expect(selection.isReversed).toBe(true) + }) + + it("should return false when anchor equals active", () => { + const selection = new Selection(5, 10, 5, 10) + expect(selection.isReversed).toBe(false) + }) + + it("should return true when same line but active character is before anchor", () => { + const selection = new Selection(5, 10, 5, 5) + expect(selection.isReversed).toBe(true) + }) + + it("should return false when same line and active character is after anchor", () => { + const selection = new Selection(5, 5, 5, 10) + expect(selection.isReversed).toBe(false) + }) + }) + + describe("inherited Range properties", () => { + it("should have isEmpty property", () => { + const emptySelection = new Selection(5, 10, 5, 10) + expect(emptySelection.isEmpty).toBe(true) + + const nonEmptySelection = new Selection(0, 0, 5, 10) + expect(nonEmptySelection.isEmpty).toBe(false) + }) + + it("should have isSingleLine property", () => { + const singleLineSelection = new Selection(5, 0, 5, 10) + expect(singleLineSelection.isSingleLine).toBe(true) + + const multiLineSelection = new Selection(0, 0, 5, 10) + expect(multiLineSelection.isSingleLine).toBe(false) + }) + + it("should support contains method", () => { + const selection = new Selection(0, 0, 10, 10) + const pos = new Position(5, 5) + expect(selection.contains(pos)).toBe(true) + + const outsidePos = new Position(15, 5) + expect(selection.contains(outsidePos)).toBe(false) + }) + + it("should support isEqual method", () => { + const selection1 = new Selection(0, 0, 5, 10) + const selection2 = new Selection(0, 0, 5, 10) + const selection3 = new Selection(0, 0, 5, 11) + + expect(selection1.isEqual(selection2)).toBe(true) + expect(selection1.isEqual(selection3)).toBe(false) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/StatusBarItem.test.ts b/packages/vscode-shim/src/__tests__/StatusBarItem.test.ts new file mode 100644 index 0000000000..9610357b14 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/StatusBarItem.test.ts @@ -0,0 +1,214 @@ +import { StatusBarItem } from "../classes/StatusBarItem.js" +import { StatusBarAlignment } from "../types.js" + +describe("StatusBarItem", () => { + describe("constructor", () => { + it("should create with alignment", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.alignment).toBe(StatusBarAlignment.Left) + }) + + it("should create with alignment and priority", () => { + const item = new StatusBarItem(StatusBarAlignment.Right, 100) + + expect(item.alignment).toBe(StatusBarAlignment.Right) + expect(item.priority).toBe(100) + }) + + it("should have undefined priority when not provided", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.priority).toBeUndefined() + }) + }) + + describe("text property", () => { + it("should have empty text initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.text).toBe("") + }) + + it("should allow setting text", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.text = "Hello" + + expect(item.text).toBe("Hello") + }) + }) + + describe("tooltip property", () => { + it("should be undefined initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.tooltip).toBeUndefined() + }) + + it("should allow setting tooltip", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.tooltip = "My tooltip" + + expect(item.tooltip).toBe("My tooltip") + }) + + it("should allow setting to undefined", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + item.tooltip = "tooltip" + + item.tooltip = undefined + + expect(item.tooltip).toBeUndefined() + }) + }) + + describe("command property", () => { + it("should be undefined initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.command).toBeUndefined() + }) + + it("should allow setting command", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.command = "myExtension.doSomething" + + expect(item.command).toBe("myExtension.doSomething") + }) + }) + + describe("color property", () => { + it("should be undefined initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.color).toBeUndefined() + }) + + it("should allow setting color", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.color = "#ff0000" + + expect(item.color).toBe("#ff0000") + }) + }) + + describe("backgroundColor property", () => { + it("should be undefined initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.backgroundColor).toBeUndefined() + }) + + it("should allow setting backgroundColor", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.backgroundColor = "#00ff00" + + expect(item.backgroundColor).toBe("#00ff00") + }) + }) + + describe("isVisible property", () => { + it("should be false initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.isVisible).toBe(false) + }) + + it("should be true after show()", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.show() + + expect(item.isVisible).toBe(true) + }) + + it("should be false after hide()", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + item.show() + + item.hide() + + expect(item.isVisible).toBe(false) + }) + }) + + describe("show()", () => { + it("should make item visible", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.show() + + expect(item.isVisible).toBe(true) + }) + + it("should be idempotent", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.show() + item.show() + + expect(item.isVisible).toBe(true) + }) + }) + + describe("hide()", () => { + it("should make item invisible", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + item.show() + + item.hide() + + expect(item.isVisible).toBe(false) + }) + + it("should be safe to call when already hidden", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(() => item.hide()).not.toThrow() + expect(item.isVisible).toBe(false) + }) + }) + + describe("dispose()", () => { + it("should make item invisible", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + item.show() + + item.dispose() + + expect(item.isVisible).toBe(false) + }) + + it("should be safe to call multiple times", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(() => { + item.dispose() + item.dispose() + }).not.toThrow() + }) + }) + + describe("alignment property", () => { + it("should be readonly", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + // TypeScript prevents reassignment at compile time + // Just verify the value is what we expect + expect(item.alignment).toBe(StatusBarAlignment.Left) + }) + }) + + describe("priority property", () => { + it("should be readonly", () => { + const item = new StatusBarItem(StatusBarAlignment.Left, 50) + + expect(item.priority).toBe(50) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/TabGroupsAPI.test.ts b/packages/vscode-shim/src/__tests__/TabGroupsAPI.test.ts new file mode 100644 index 0000000000..6337a9a14d --- /dev/null +++ b/packages/vscode-shim/src/__tests__/TabGroupsAPI.test.ts @@ -0,0 +1,163 @@ +import { TabGroupsAPI, type Tab, type TabGroup } from "../api/TabGroupsAPI.js" +import { Uri } from "../classes/Uri.js" + +describe("TabGroupsAPI", () => { + let tabGroups: TabGroupsAPI + + beforeEach(() => { + tabGroups = new TabGroupsAPI() + }) + + describe("all property", () => { + it("should return empty array initially", () => { + expect(tabGroups.all).toEqual([]) + }) + + it("should return array of TabGroup", () => { + expect(Array.isArray(tabGroups.all)).toBe(true) + }) + }) + + describe("onDidChangeTabs()", () => { + it("should return a disposable", () => { + const disposable = tabGroups.onDidChangeTabs(() => {}) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + + it("should call listener when _simulateTabChange is called", () => { + const listener = vi.fn() + tabGroups.onDidChangeTabs(listener) + + tabGroups._simulateTabChange() + + expect(listener).toHaveBeenCalledTimes(1) + }) + + it("should not call listener after dispose", () => { + const listener = vi.fn() + const disposable = tabGroups.onDidChangeTabs(listener) + + disposable.dispose() + tabGroups._simulateTabChange() + + expect(listener).not.toHaveBeenCalled() + }) + + it("should support multiple listeners", () => { + const listener1 = vi.fn() + const listener2 = vi.fn() + + tabGroups.onDidChangeTabs(listener1) + tabGroups.onDidChangeTabs(listener2) + tabGroups._simulateTabChange() + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + }) + }) + + describe("close()", () => { + it("should return false when tab is not found", async () => { + const mockTab: Tab = { + input: { uri: Uri.file("/test/file.txt") }, + label: "file.txt", + isActive: true, + isDirty: false, + } + + const result = await tabGroups.close(mockTab) + + expect(result).toBe(false) + }) + + it("should return a promise", () => { + const mockTab: Tab = { + input: { uri: Uri.file("/test/file.txt") }, + label: "file.txt", + isActive: true, + isDirty: false, + } + + const result = tabGroups.close(mockTab) + + expect(result).toBeInstanceOf(Promise) + }) + }) + + describe("_simulateTabChange()", () => { + it("should fire the onDidChangeTabs event", () => { + const listener = vi.fn() + tabGroups.onDidChangeTabs(listener) + + tabGroups._simulateTabChange() + + expect(listener).toHaveBeenCalled() + }) + }) + + describe("dispose()", () => { + it("should not throw when called", () => { + expect(() => tabGroups.dispose()).not.toThrow() + }) + + it("should stop firing events after dispose", () => { + const listener = vi.fn() + tabGroups.onDidChangeTabs(listener) + + tabGroups.dispose() + // After dispose, internal emitter is disposed so new events shouldn't fire + // But existing listeners may still be registered + }) + + it("should be safe to call multiple times", () => { + expect(() => { + tabGroups.dispose() + tabGroups.dispose() + }).not.toThrow() + }) + }) +}) + +describe("Tab interface", () => { + it("should have required properties", () => { + const tab: Tab = { + input: { uri: Uri.file("/test/file.txt") }, + label: "file.txt", + isActive: true, + isDirty: false, + } + + expect(tab.input).toBeDefined() + expect(tab.label).toBe("file.txt") + expect(tab.isActive).toBe(true) + expect(tab.isDirty).toBe(false) + }) +}) + +describe("TabGroup interface", () => { + it("should have tabs array", () => { + const tabGroup: TabGroup = { + tabs: [], + } + + expect(Array.isArray(tabGroup.tabs)).toBe(true) + }) + + it("should contain Tab objects", () => { + const tab: Tab = { + input: { uri: Uri.file("/test/file.txt") }, + label: "file.txt", + isActive: true, + isDirty: false, + } + + const tabGroup: TabGroup = { + tabs: [tab], + } + + expect(tabGroup.tabs).toHaveLength(1) + expect(tabGroup.tabs[0]).toBe(tab) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/TextEdit.test.ts b/packages/vscode-shim/src/__tests__/TextEdit.test.ts new file mode 100644 index 0000000000..03ac93475b --- /dev/null +++ b/packages/vscode-shim/src/__tests__/TextEdit.test.ts @@ -0,0 +1,263 @@ +import { TextEdit, WorkspaceEdit } from "../classes/TextEdit.js" +import { Position } from "../classes/Position.js" +import { Range } from "../classes/Range.js" +import { Uri } from "../classes/Uri.js" + +describe("TextEdit", () => { + describe("constructor", () => { + it("should create a TextEdit with range and newText", () => { + const range = new Range(0, 0, 0, 5) + const edit = new TextEdit(range, "hello") + + expect(edit.range.start.line).toBe(0) + expect(edit.range.start.character).toBe(0) + expect(edit.range.end.line).toBe(0) + expect(edit.range.end.character).toBe(5) + expect(edit.newText).toBe("hello") + }) + }) + + describe("replace()", () => { + it("should create a replace edit", () => { + const range = new Range(1, 0, 1, 10) + const edit = TextEdit.replace(range, "replacement") + + expect(edit.range.isEqual(range)).toBe(true) + expect(edit.newText).toBe("replacement") + }) + + it("should handle multi-line ranges", () => { + const range = new Range(0, 0, 5, 10) + const edit = TextEdit.replace(range, "new content") + + expect(edit.range.start.line).toBe(0) + expect(edit.range.end.line).toBe(5) + expect(edit.newText).toBe("new content") + }) + }) + + describe("insert()", () => { + it("should create an insert edit at position", () => { + const position = new Position(5, 10) + const edit = TextEdit.insert(position, "inserted text") + + expect(edit.range.start.line).toBe(5) + expect(edit.range.start.character).toBe(10) + expect(edit.range.end.line).toBe(5) + expect(edit.range.end.character).toBe(10) + expect(edit.range.isEmpty).toBe(true) + expect(edit.newText).toBe("inserted text") + }) + + it("should handle insert at beginning of file", () => { + const position = new Position(0, 0) + const edit = TextEdit.insert(position, "prefix") + + expect(edit.range.start.isEqual(position)).toBe(true) + expect(edit.newText).toBe("prefix") + }) + }) + + describe("delete()", () => { + it("should create a delete edit", () => { + const range = new Range(0, 5, 0, 10) + const edit = TextEdit.delete(range) + + expect(edit.range.isEqual(range)).toBe(true) + expect(edit.newText).toBe("") + }) + + it("should handle multi-line deletion", () => { + const range = new Range(0, 0, 5, 0) + const edit = TextEdit.delete(range) + + expect(edit.range.start.line).toBe(0) + expect(edit.range.end.line).toBe(5) + expect(edit.newText).toBe("") + }) + }) + + describe("setEndOfLine()", () => { + it("should create a setEndOfLine edit", () => { + const edit = TextEdit.setEndOfLine() + + expect(edit.range.start.line).toBe(0) + expect(edit.range.start.character).toBe(0) + expect(edit.newText).toBe("") + }) + }) +}) + +describe("WorkspaceEdit", () => { + describe("set() and get()", () => { + it("should set and get edits for a URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + const edits = [ + TextEdit.replace(new Range(0, 0, 0, 5), "hello"), + TextEdit.insert(new Position(1, 0), "world"), + ] + + workspaceEdit.set(uri, edits) + const retrieved = workspaceEdit.get(uri) + + expect(retrieved).toHaveLength(2) + expect(retrieved[0]?.newText).toBe("hello") + expect(retrieved[1]?.newText).toBe("world") + }) + + it("should return empty array for unknown URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/nonexistent.txt") + + expect(workspaceEdit.get(uri)).toEqual([]) + }) + + it("should overwrite edits when setting same URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + + workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "first")]) + workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "second")]) + + const edits = workspaceEdit.get(uri) + expect(edits).toHaveLength(1) + expect(edits[0]?.newText).toBe("second") + }) + }) + + describe("has()", () => { + it("should return true when URI has edits", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + + workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "text")]) + + expect(workspaceEdit.has(uri)).toBe(true) + }) + + it("should return false when URI has no edits", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + + expect(workspaceEdit.has(uri)).toBe(false) + }) + }) + + describe("delete()", () => { + it("should add a delete edit for URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + const range = new Range(0, 5, 0, 10) + + workspaceEdit.delete(uri, range) + + const edits = workspaceEdit.get(uri) + expect(edits).toHaveLength(1) + expect(edits[0]?.newText).toBe("") + expect(edits[0]?.range.start.character).toBe(5) + expect(edits[0]?.range.end.character).toBe(10) + }) + + it("should append to existing edits", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + + workspaceEdit.insert(uri, new Position(0, 0), "text") + workspaceEdit.delete(uri, new Range(1, 0, 1, 5)) + + const edits = workspaceEdit.get(uri) + expect(edits).toHaveLength(2) + }) + }) + + describe("insert()", () => { + it("should add an insert edit for URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + const position = new Position(5, 10) + + workspaceEdit.insert(uri, position, "inserted") + + const edits = workspaceEdit.get(uri) + expect(edits).toHaveLength(1) + expect(edits[0]?.newText).toBe("inserted") + expect(edits[0]?.range.start.line).toBe(5) + expect(edits[0]?.range.start.character).toBe(10) + }) + }) + + describe("replace()", () => { + it("should add a replace edit for URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + const range = new Range(0, 0, 0, 10) + + workspaceEdit.replace(uri, range, "replacement") + + const edits = workspaceEdit.get(uri) + expect(edits).toHaveLength(1) + expect(edits[0]?.newText).toBe("replacement") + expect(edits[0]?.range.start.line).toBe(0) + expect(edits[0]?.range.end.character).toBe(10) + }) + }) + + describe("size", () => { + it("should return 0 for empty WorkspaceEdit", () => { + const workspaceEdit = new WorkspaceEdit() + expect(workspaceEdit.size).toBe(0) + }) + + it("should return number of documents with edits", () => { + const workspaceEdit = new WorkspaceEdit() + const uri1 = Uri.file("/path/to/file1.txt") + const uri2 = Uri.file("/path/to/file2.txt") + const uri3 = Uri.file("/path/to/file3.txt") + + workspaceEdit.insert(uri1, new Position(0, 0), "text1") + workspaceEdit.insert(uri2, new Position(0, 0), "text2") + workspaceEdit.insert(uri3, new Position(0, 0), "text3") + + expect(workspaceEdit.size).toBe(3) + }) + + it("should count same URI only once", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + + workspaceEdit.insert(uri, new Position(0, 0), "text1") + workspaceEdit.insert(uri, new Position(1, 0), "text2") + workspaceEdit.insert(uri, new Position(2, 0), "text3") + + expect(workspaceEdit.size).toBe(1) + }) + }) + + describe("entries()", () => { + it("should return empty array for empty WorkspaceEdit", () => { + const workspaceEdit = new WorkspaceEdit() + expect(workspaceEdit.entries()).toEqual([]) + }) + + it("should return all URI/edits pairs", () => { + const workspaceEdit = new WorkspaceEdit() + const uri1 = Uri.file("/path/to/file1.txt") + const uri2 = Uri.file("/path/to/file2.txt") + + workspaceEdit.insert(uri1, new Position(0, 0), "text1") + workspaceEdit.replace(uri2, new Range(0, 0, 0, 5), "text2") + + const entries = workspaceEdit.entries() + expect(entries).toHaveLength(2) + + // Entries should have URI-like objects with toString and fsPath + expect(typeof entries[0]?.[0]?.toString).toBe("function") + expect(typeof entries[0]?.[0]?.fsPath).toBe("string") + + // Should contain the edits + expect(entries.some((e) => e[1][0]?.newText === "text1")).toBe(true) + expect(entries.some((e) => e[1][0]?.newText === "text2")).toBe(true) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/TextEditorDecorationType.test.ts b/packages/vscode-shim/src/__tests__/TextEditorDecorationType.test.ts new file mode 100644 index 0000000000..f1ff27ad41 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/TextEditorDecorationType.test.ts @@ -0,0 +1,59 @@ +import { TextEditorDecorationType } from "../classes/TextEditorDecorationType.js" + +describe("TextEditorDecorationType", () => { + describe("constructor", () => { + it("should create with a key", () => { + const decoration = new TextEditorDecorationType("my-decoration") + + expect(decoration.key).toBe("my-decoration") + }) + + it("should allow any string key", () => { + const decoration = new TextEditorDecorationType("decoration-12345") + + expect(decoration.key).toBe("decoration-12345") + }) + }) + + describe("key property", () => { + it("should be accessible", () => { + const decoration = new TextEditorDecorationType("test-key") + + expect(decoration.key).toBe("test-key") + }) + + it("should be mutable", () => { + const decoration = new TextEditorDecorationType("original") + + decoration.key = "modified" + + expect(decoration.key).toBe("modified") + }) + }) + + describe("dispose()", () => { + it("should not throw when called", () => { + const decoration = new TextEditorDecorationType("test") + + expect(() => decoration.dispose()).not.toThrow() + }) + + it("should be safe to call multiple times", () => { + const decoration = new TextEditorDecorationType("test") + + expect(() => { + decoration.dispose() + decoration.dispose() + decoration.dispose() + }).not.toThrow() + }) + }) + + describe("Disposable interface", () => { + it("should implement Disposable interface", () => { + const decoration = new TextEditorDecorationType("test") + + expect(typeof decoration.dispose).toBe("function") + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/Uri.test.ts b/packages/vscode-shim/src/__tests__/Uri.test.ts new file mode 100644 index 0000000000..6988ccb219 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/Uri.test.ts @@ -0,0 +1,102 @@ +import { Uri } from "../classes/Uri.js" + +describe("Uri", () => { + describe("file()", () => { + it("should create a file URI", () => { + const uri = Uri.file("/path/to/file.txt") + expect(uri.scheme).toBe("file") + expect(uri.path).toBe("/path/to/file.txt") + expect(uri.fsPath).toBe("/path/to/file.txt") + }) + + it("should handle Windows paths", () => { + const uri = Uri.file("C:\\Users\\test\\file.txt") + expect(uri.scheme).toBe("file") + expect(uri.fsPath).toBe("C:\\Users\\test\\file.txt") + }) + }) + + describe("parse()", () => { + it("should parse HTTP URLs", () => { + const uri = Uri.parse("https://example.com/path?query=1#fragment") + expect(uri.scheme).toBe("https") + expect(uri.authority).toBe("example.com") + expect(uri.path).toBe("/path") + expect(uri.query).toBe("query=1") + expect(uri.fragment).toBe("fragment") + }) + + it("should parse file URLs", () => { + const uri = Uri.parse("file:///path/to/file.txt") + expect(uri.scheme).toBe("file") + expect(uri.path).toBe("/path/to/file.txt") + }) + + it("should handle invalid URLs by treating as file paths", () => { + const uri = Uri.parse("/just/a/path") + expect(uri.scheme).toBe("file") + expect(uri.fsPath).toBe("/just/a/path") + }) + }) + + describe("joinPath()", () => { + it("should join path segments", () => { + const base = Uri.file("/base/path") + const joined = Uri.joinPath(base, "sub", "file.txt") + expect(joined.fsPath).toContain("sub") + expect(joined.fsPath).toContain("file.txt") + }) + }) + + describe("with()", () => { + it("should create new URI with modified scheme", () => { + const uri = Uri.file("/path/to/file.txt") + const modified = uri.with({ scheme: "vscode" }) + expect(modified.scheme).toBe("vscode") + expect(modified.path).toBe("/path/to/file.txt") + }) + + it("should create new URI with modified path", () => { + const uri = Uri.parse("https://example.com/old/path") + const modified = uri.with({ path: "/new/path" }) + expect(modified.path).toBe("/new/path") + expect(modified.scheme).toBe("https") + }) + + it("should preserve unchanged properties", () => { + const uri = Uri.parse("https://example.com/path?query=1#fragment") + const modified = uri.with({ path: "/newpath" }) + expect(modified.scheme).toBe("https") + expect(modified.query).toBe("query=1") + expect(modified.fragment).toBe("fragment") + }) + }) + + describe("toString()", () => { + it("should convert to URI string", () => { + const uri = Uri.parse("https://example.com/path?query=1#fragment") + const str = uri.toString() + expect(str).toBe("https://example.com/path?query=1#fragment") + }) + + it("should handle file URIs", () => { + const uri = Uri.file("/path/to/file.txt") + const str = uri.toString() + expect(str).toBe("file:///path/to/file.txt") + }) + }) + + describe("toJSON()", () => { + it("should convert to JSON object", () => { + const uri = Uri.parse("https://example.com/path?query=1#fragment") + const json = uri.toJSON() + expect(json).toEqual({ + scheme: "https", + authority: "example.com", + path: "/path", + query: "query=1", + fragment: "fragment", + }) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/WindowAPI.test.ts b/packages/vscode-shim/src/__tests__/WindowAPI.test.ts new file mode 100644 index 0000000000..5af6355b55 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/WindowAPI.test.ts @@ -0,0 +1,305 @@ +import { WindowAPI } from "../api/WindowAPI.js" +import { Uri } from "../classes/Uri.js" +import { StatusBarAlignment } from "../types.js" + +describe("WindowAPI", () => { + let windowAPI: WindowAPI + + beforeEach(() => { + windowAPI = new WindowAPI() + }) + + describe("tabGroups property", () => { + it("should have tabGroups", () => { + expect(windowAPI.tabGroups).toBeDefined() + }) + + it("should return TabGroupsAPI instance", () => { + expect(typeof windowAPI.tabGroups.onDidChangeTabs).toBe("function") + expect(Array.isArray(windowAPI.tabGroups.all)).toBe(true) + }) + }) + + describe("visibleTextEditors property", () => { + it("should be an empty array initially", () => { + expect(windowAPI.visibleTextEditors).toEqual([]) + }) + }) + + describe("createOutputChannel()", () => { + it("should create an output channel with the given name", () => { + const channel = windowAPI.createOutputChannel("TestChannel") + + expect(channel.name).toBe("TestChannel") + }) + + it("should return an OutputChannel instance", () => { + const channel = windowAPI.createOutputChannel("Test") + + expect(typeof channel.append).toBe("function") + expect(typeof channel.appendLine).toBe("function") + expect(typeof channel.dispose).toBe("function") + }) + }) + + describe("createStatusBarItem()", () => { + it("should create with default alignment", () => { + const item = windowAPI.createStatusBarItem() + + expect(item.alignment).toBe(StatusBarAlignment.Left) + }) + + it("should create with specified alignment", () => { + const item = windowAPI.createStatusBarItem(StatusBarAlignment.Right) + + expect(item.alignment).toBe(StatusBarAlignment.Right) + }) + + it("should create with alignment and priority", () => { + const item = windowAPI.createStatusBarItem(StatusBarAlignment.Left, 100) + + expect(item.alignment).toBe(StatusBarAlignment.Left) + expect(item.priority).toBe(100) + }) + + it("should handle overloaded signature with id", () => { + const item = windowAPI.createStatusBarItem("myId", StatusBarAlignment.Right, 50) + + expect(item.alignment).toBe(StatusBarAlignment.Right) + expect(item.priority).toBe(50) + }) + }) + + describe("createTextEditorDecorationType()", () => { + it("should create a decoration type", () => { + const decoration = windowAPI.createTextEditorDecorationType({}) + + expect(decoration).toBeDefined() + expect(decoration.key).toContain("decoration-") + }) + + it("should return unique keys", () => { + const decoration1 = windowAPI.createTextEditorDecorationType({}) + const decoration2 = windowAPI.createTextEditorDecorationType({}) + + expect(decoration1.key).not.toBe(decoration2.key) + }) + }) + + describe("createTerminal()", () => { + it("should create a terminal with default name", () => { + const terminal = windowAPI.createTerminal() + + expect(terminal.name).toBe("Terminal") + }) + + it("should create a terminal with specified name", () => { + const terminal = windowAPI.createTerminal({ name: "MyTerminal" }) + + expect(terminal.name).toBe("MyTerminal") + }) + + it("should return terminal with expected methods", () => { + const terminal = windowAPI.createTerminal() + + expect(typeof terminal.sendText).toBe("function") + expect(typeof terminal.show).toBe("function") + expect(typeof terminal.hide).toBe("function") + expect(typeof terminal.dispose).toBe("function") + }) + + it("should have processId promise", async () => { + const terminal = windowAPI.createTerminal() + + const processId = await terminal.processId + + expect(processId).toBeUndefined() + }) + }) + + describe("showInformationMessage()", () => { + it("should return a promise", () => { + const result = windowAPI.showInformationMessage("Test message") + + expect(result).toBeInstanceOf(Promise) + }) + + it("should resolve to undefined", async () => { + const result = await windowAPI.showInformationMessage("Test message") + + expect(result).toBeUndefined() + }) + }) + + describe("showWarningMessage()", () => { + it("should return a promise", () => { + const result = windowAPI.showWarningMessage("Warning message") + + expect(result).toBeInstanceOf(Promise) + }) + + it("should resolve to undefined", async () => { + const result = await windowAPI.showWarningMessage("Warning message") + + expect(result).toBeUndefined() + }) + }) + + describe("showErrorMessage()", () => { + it("should return a promise", () => { + const result = windowAPI.showErrorMessage("Error message") + + expect(result).toBeInstanceOf(Promise) + }) + + it("should resolve to undefined", async () => { + const result = await windowAPI.showErrorMessage("Error message") + + expect(result).toBeUndefined() + }) + }) + + describe("showQuickPick()", () => { + it("should return first item", async () => { + const result = await windowAPI.showQuickPick(["item1", "item2", "item3"]) + + expect(result).toBe("item1") + }) + + it("should return undefined for empty array", async () => { + const result = await windowAPI.showQuickPick([]) + + expect(result).toBeUndefined() + }) + }) + + describe("showInputBox()", () => { + it("should return empty string", async () => { + const result = await windowAPI.showInputBox() + + expect(result).toBe("") + }) + }) + + describe("showOpenDialog()", () => { + it("should return empty array", async () => { + const result = await windowAPI.showOpenDialog() + + expect(result).toEqual([]) + }) + }) + + describe("showTextDocument()", () => { + it("should return an editor", async () => { + const uri = Uri.file("/test/file.txt") + const editor = await windowAPI.showTextDocument(uri) + + expect(editor).toBeDefined() + expect(editor.document).toBeDefined() + }) + + it("should add editor to visibleTextEditors", async () => { + const uri = Uri.file("/test/file.txt") + await windowAPI.showTextDocument(uri) + + expect(windowAPI.visibleTextEditors.length).toBeGreaterThan(0) + }) + }) + + describe("registerWebviewViewProvider()", () => { + it("should return a disposable", () => { + const mockProvider = { + resolveWebviewView: vi.fn(), + } + + const disposable = windowAPI.registerWebviewViewProvider("myView", mockProvider) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("registerUriHandler()", () => { + it("should return a disposable", () => { + const mockHandler = { + handleUri: vi.fn(), + } + + const disposable = windowAPI.registerUriHandler(mockHandler) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("onDidChangeTextEditorSelection()", () => { + it("should return a disposable", () => { + const disposable = windowAPI.onDidChangeTextEditorSelection(() => {}) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("onDidChangeActiveTextEditor()", () => { + it("should return a disposable", () => { + const disposable = windowAPI.onDidChangeActiveTextEditor(() => {}) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("onDidChangeVisibleTextEditors()", () => { + it("should return a disposable", () => { + const disposable = windowAPI.onDidChangeVisibleTextEditors(() => {}) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("terminal events", () => { + it("onDidCloseTerminal should return disposable", () => { + const disposable = windowAPI.onDidCloseTerminal(() => {}) + + expect(typeof disposable.dispose).toBe("function") + }) + + it("onDidOpenTerminal should return disposable", () => { + const disposable = windowAPI.onDidOpenTerminal(() => {}) + + expect(typeof disposable.dispose).toBe("function") + }) + + it("onDidChangeActiveTerminal should return disposable", () => { + const disposable = windowAPI.onDidChangeActiveTerminal(() => {}) + + expect(typeof disposable.dispose).toBe("function") + }) + + it("onDidChangeTerminalDimensions should return disposable", () => { + const disposable = windowAPI.onDidChangeTerminalDimensions(() => {}) + + expect(typeof disposable.dispose).toBe("function") + }) + + it("onDidWriteTerminalData should return disposable", () => { + const disposable = windowAPI.onDidWriteTerminalData(() => {}) + + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("activeTerminal property", () => { + it("should return undefined", () => { + expect(windowAPI.activeTerminal).toBeUndefined() + }) + }) + + describe("terminals property", () => { + it("should return empty array", () => { + expect(windowAPI.terminals).toEqual([]) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/WorkspaceAPI.test.ts b/packages/vscode-shim/src/__tests__/WorkspaceAPI.test.ts new file mode 100644 index 0000000000..449195d825 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/WorkspaceAPI.test.ts @@ -0,0 +1,290 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { WorkspaceAPI } from "../api/WorkspaceAPI.js" +import { Uri } from "../classes/Uri.js" +import { Range } from "../classes/Range.js" +import { Position } from "../classes/Position.js" +import { WorkspaceEdit } from "../classes/TextEdit.js" +import { ExtensionContextImpl } from "../context/ExtensionContext.js" + +describe("WorkspaceAPI", () => { + let tempDir: string + let extensionPath: string + let workspacePath: string + let context: ExtensionContextImpl + let workspaceAPI: WorkspaceAPI + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "workspace-api-test-")) + extensionPath = path.join(tempDir, "extension") + workspacePath = path.join(tempDir, "workspace") + fs.mkdirSync(extensionPath, { recursive: true }) + fs.mkdirSync(workspacePath, { recursive: true }) + + context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + + workspaceAPI = new WorkspaceAPI(workspacePath, context) + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + describe("workspaceFolders", () => { + it("should have workspace folder set", () => { + expect(workspaceAPI.workspaceFolders).toHaveLength(1) + expect(workspaceAPI.workspaceFolders?.[0]?.uri.fsPath).toBe(workspacePath) + expect(workspaceAPI.workspaceFolders?.[0]?.index).toBe(0) + }) + + it("should have workspace name set", () => { + expect(workspaceAPI.name).toBe(path.basename(workspacePath)) + }) + }) + + describe("asRelativePath()", () => { + it("should convert absolute path to relative", () => { + const absolutePath = path.join(workspacePath, "subdir", "file.txt") + const relativePath = workspaceAPI.asRelativePath(absolutePath) + + expect(relativePath).toBe(path.join("subdir", "file.txt")) + }) + + it("should handle URI input", () => { + const uri = Uri.file(path.join(workspacePath, "file.txt")) + const relativePath = workspaceAPI.asRelativePath(uri) + + expect(relativePath).toBe("file.txt") + }) + + it("should return original path if outside workspace", () => { + const outsidePath = "/outside/workspace/file.txt" + const result = workspaceAPI.asRelativePath(outsidePath) + + expect(result).toBe(outsidePath) + }) + + it("should handle empty workspace folders", () => { + workspaceAPI.workspaceFolders = undefined + const absolutePath = "/some/path/file.txt" + const result = workspaceAPI.asRelativePath(absolutePath) + + expect(result).toBe(absolutePath) + }) + }) + + describe("getConfiguration()", () => { + it("should return configuration object", () => { + const config = workspaceAPI.getConfiguration("myExtension") + + expect(config).toBeDefined() + expect(typeof config.get).toBe("function") + expect(typeof config.has).toBe("function") + expect(typeof config.update).toBe("function") + }) + }) + + describe("findFiles()", () => { + it("should return empty array (minimal implementation)", async () => { + const result = await workspaceAPI.findFiles("**/*.txt") + + expect(result).toEqual([]) + }) + }) + + describe("openTextDocument()", () => { + it("should open and return a text document", async () => { + const filePath = path.join(workspacePath, "test.txt") + fs.writeFileSync(filePath, "Line 1\nLine 2\nLine 3") + + const uri = Uri.file(filePath) + const document = await workspaceAPI.openTextDocument(uri) + + expect(document.uri.fsPath).toBe(filePath) + expect(document.fileName).toBe(filePath) + expect(document.lineCount).toBe(3) + expect(document.getText()).toBe("Line 1\nLine 2\nLine 3") + }) + + it("should handle getText with range", async () => { + const filePath = path.join(workspacePath, "test.txt") + fs.writeFileSync(filePath, "Line 1\nLine 2\nLine 3") + + const uri = Uri.file(filePath) + const document = await workspaceAPI.openTextDocument(uri) + + const range = new Range(0, 0, 1, 6) + const text = document.getText(range) + + expect(text).toContain("Line 1") + expect(text).toContain("Line 2") + }) + + it("should provide lineAt method", async () => { + const filePath = path.join(workspacePath, "test.txt") + fs.writeFileSync(filePath, "Hello\nWorld") + + const uri = Uri.file(filePath) + const document = await workspaceAPI.openTextDocument(uri) + + const line = document.lineAt(0) + + expect(line.text).toBe("Hello") + expect(line.isEmptyOrWhitespace).toBe(false) + }) + + it("should add document to textDocuments", async () => { + const filePath = path.join(workspacePath, "test.txt") + fs.writeFileSync(filePath, "content") + + const uri = Uri.file(filePath) + await workspaceAPI.openTextDocument(uri) + + expect(workspaceAPI.textDocuments).toHaveLength(1) + }) + + it("should handle non-existent file gracefully", async () => { + const uri = Uri.file(path.join(workspacePath, "nonexistent.txt")) + const document = await workspaceAPI.openTextDocument(uri) + + expect(document.getText()).toBe("") + expect(document.lineCount).toBe(1) + }) + }) + + describe("applyEdit()", () => { + it("should apply single edit to file", async () => { + const filePath = path.join(workspacePath, "edit-test.txt") + fs.writeFileSync(filePath, "Hello World") + + const edit = new WorkspaceEdit() + const uri = Uri.file(filePath) + edit.replace(uri, new Range(0, 0, 0, 5), "Hi") + + const result = await workspaceAPI.applyEdit(edit) + + expect(result).toBe(true) + expect(fs.readFileSync(filePath, "utf-8")).toBe("Hi World") + }) + + it("should apply insert edit", async () => { + const filePath = path.join(workspacePath, "insert-test.txt") + fs.writeFileSync(filePath, "World") + + const edit = new WorkspaceEdit() + const uri = Uri.file(filePath) + edit.insert(uri, new Position(0, 0), "Hello ") + + const result = await workspaceAPI.applyEdit(edit) + + expect(result).toBe(true) + expect(fs.readFileSync(filePath, "utf-8")).toBe("Hello World") + }) + + it("should apply delete edit", async () => { + const filePath = path.join(workspacePath, "delete-test.txt") + fs.writeFileSync(filePath, "Hello World") + + const edit = new WorkspaceEdit() + const uri = Uri.file(filePath) + edit.delete(uri, new Range(0, 5, 0, 11)) + + const result = await workspaceAPI.applyEdit(edit) + + expect(result).toBe(true) + expect(fs.readFileSync(filePath, "utf-8")).toBe("Hello") + }) + + it("should create file if it doesn't exist", async () => { + const filePath = path.join(workspacePath, "new-file.txt") + + const edit = new WorkspaceEdit() + const uri = Uri.file(filePath) + edit.insert(uri, new Position(0, 0), "New content") + + const result = await workspaceAPI.applyEdit(edit) + + expect(result).toBe(true) + expect(fs.readFileSync(filePath, "utf-8")).toBe("New content") + }) + + it("should update in-memory document", async () => { + const filePath = path.join(workspacePath, "memory-test.txt") + fs.writeFileSync(filePath, "Original") + + // First open the document + const uri = Uri.file(filePath) + const document = await workspaceAPI.openTextDocument(uri) + expect(document.getText()).toBe("Original") + + // Apply edit + const edit = new WorkspaceEdit() + edit.replace(uri, new Range(0, 0, 0, 8), "Modified") + await workspaceAPI.applyEdit(edit) + + // Check in-memory document is updated + expect(document.getText()).toBe("Modified") + }) + }) + + describe("createFileSystemWatcher()", () => { + it("should return a file system watcher object", () => { + const watcher = workspaceAPI.createFileSystemWatcher() + + expect(typeof watcher.onDidChange).toBe("function") + expect(typeof watcher.onDidCreate).toBe("function") + expect(typeof watcher.onDidDelete).toBe("function") + expect(typeof watcher.dispose).toBe("function") + }) + }) + + describe("events", () => { + it("should have onDidChangeWorkspaceFolders event", () => { + expect(typeof workspaceAPI.onDidChangeWorkspaceFolders).toBe("function") + }) + + it("should have onDidOpenTextDocument event", () => { + expect(typeof workspaceAPI.onDidOpenTextDocument).toBe("function") + }) + + it("should have onDidChangeTextDocument event", () => { + expect(typeof workspaceAPI.onDidChangeTextDocument).toBe("function") + }) + + it("should have onDidCloseTextDocument event", () => { + expect(typeof workspaceAPI.onDidCloseTextDocument).toBe("function") + }) + + it("should have onDidChangeConfiguration event", () => { + expect(typeof workspaceAPI.onDidChangeConfiguration).toBe("function") + }) + }) + + describe("fs property", () => { + it("should have FileSystemAPI instance", () => { + expect(workspaceAPI.fs).toBeDefined() + expect(typeof workspaceAPI.fs.stat).toBe("function") + expect(typeof workspaceAPI.fs.readFile).toBe("function") + expect(typeof workspaceAPI.fs.writeFile).toBe("function") + }) + }) + + describe("registerTextDocumentContentProvider()", () => { + it("should return a disposable", () => { + const disposable = workspaceAPI.registerTextDocumentContentProvider("test", { + provideTextDocumentContent: () => Promise.resolve("content"), + }) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/WorkspaceConfiguration.test.ts b/packages/vscode-shim/src/__tests__/WorkspaceConfiguration.test.ts new file mode 100644 index 0000000000..e99c91b4c4 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/WorkspaceConfiguration.test.ts @@ -0,0 +1,272 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { + MockWorkspaceConfiguration, + setRuntimeConfig, + setRuntimeConfigValues, + getRuntimeConfig, + clearRuntimeConfig, +} from "../api/WorkspaceConfiguration.js" +import { ExtensionContextImpl } from "../context/ExtensionContext.js" + +describe("MockWorkspaceConfiguration", () => { + let tempDir: string + let extensionPath: string + let workspacePath: string + let context: ExtensionContextImpl + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "config-test-")) + extensionPath = path.join(tempDir, "extension") + workspacePath = path.join(tempDir, "workspace") + fs.mkdirSync(extensionPath, { recursive: true }) + fs.mkdirSync(workspacePath, { recursive: true }) + + context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + describe("get()", () => { + it("should return default value when key doesn't exist", () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + expect(config.get("nonexistent", "default")).toBe("default") + }) + + it("should return undefined when key doesn't exist and no default provided", () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + expect(config.get("nonexistent")).toBeUndefined() + }) + + it("should return stored value", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("setting", "value") + + expect(config.get("setting")).toBe("value") + }) + + it("should use section prefix", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("nested.setting", "nested value") + + expect(config.get("nested.setting")).toBe("nested value") + }) + + it("should handle complex values", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + const complexValue = { nested: { array: [1, 2, 3] } } + + await config.update("complex", complexValue) + + expect(config.get("complex")).toEqual(complexValue) + }) + }) + + describe("has()", () => { + it("should return false for non-existent key", () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + expect(config.has("nonexistent")).toBe(false) + }) + + it("should return true for existing key", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("exists", "value") + + expect(config.has("exists")).toBe(true) + }) + }) + + describe("inspect()", () => { + it("should return undefined for non-existent key", () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + expect(config.inspect("nonexistent")).toBeUndefined() + }) + + it("should return inspection result for existing key", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("setting", "global value", 1) // Global + + const inspection = config.inspect("setting") + + expect(inspection).toBeDefined() + expect(inspection?.key).toBe("myExtension.setting") + expect(inspection?.globalValue).toBe("global value") + }) + + it("should return workspace value when set", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("workspaceSetting", "workspace value", 2) // Workspace + + const inspection = config.inspect("workspaceSetting") + + expect(inspection).toBeDefined() + expect(inspection?.workspaceValue).toBe("workspace value") + }) + }) + + describe("update()", () => { + it("should update global configuration", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("globalSetting", "global value", 1) // Global + + expect(config.get("globalSetting")).toBe("global value") + }) + + it("should update workspace configuration", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("workspaceSetting", "workspace value", 2) // Workspace + + expect(config.get("workspaceSetting")).toBe("workspace value") + }) + + it("should persist configuration across instances", async () => { + const config1 = new MockWorkspaceConfiguration("myExtension", context) + await config1.update("persistent", "value") + + // Create new config instance + const config2 = new MockWorkspaceConfiguration("myExtension", context) + + expect(config2.get("persistent")).toBe("value") + }) + + it("should allow updating with null/undefined to clear value", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + await config.update("toDelete", "value") + + expect(config.get("toDelete")).toBe("value") + + await config.update("toDelete", undefined) + + expect(config.get("toDelete")).toBeUndefined() + }) + }) + + describe("reload()", () => { + it("should not throw when called", () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + expect(() => config.reload()).not.toThrow() + }) + }) + + describe("getAllConfig()", () => { + it("should return all configuration values", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + await config.update("key1", "value1") + await config.update("key2", "value2") + + const allConfig = config.getAllConfig() + + expect(allConfig["myExtension.key1"]).toBe("value1") + expect(allConfig["myExtension.key2"]).toBe("value2") + }) + }) + + describe("Runtime Configuration", () => { + beforeEach(() => { + // Clear runtime config before each test + clearRuntimeConfig() + }) + + afterEach(() => { + // Clean up after each test + clearRuntimeConfig() + }) + + it("should return runtime config value over disk-based values", async () => { + const config = new MockWorkspaceConfiguration("roo-cline", context) + + // Set a value in disk-based storage + await config.update("commandExecutionTimeout", 10) + + // Verify disk value is returned + expect(config.get("commandExecutionTimeout")).toBe(10) + + // Set runtime config (should take precedence) + setRuntimeConfig("roo-cline", "commandExecutionTimeout", 20) + + // Now runtime value should be returned + expect(config.get("commandExecutionTimeout")).toBe(20) + }) + + it("should set and get runtime config values", () => { + setRuntimeConfig("roo-cline", "testSetting", "testValue") + + expect(getRuntimeConfig("roo-cline.testSetting")).toBe("testValue") + }) + + it("should set multiple runtime config values at once", () => { + setRuntimeConfigValues("roo-cline", { + setting1: "value1", + setting2: 42, + setting3: true, + }) + + expect(getRuntimeConfig("roo-cline.setting1")).toBe("value1") + expect(getRuntimeConfig("roo-cline.setting2")).toBe(42) + expect(getRuntimeConfig("roo-cline.setting3")).toBe(true) + }) + + it("should ignore undefined values in setRuntimeConfigValues", () => { + setRuntimeConfigValues("roo-cline", { + defined: "value", + notDefined: undefined, + }) + + expect(getRuntimeConfig("roo-cline.defined")).toBe("value") + expect(getRuntimeConfig("roo-cline.notDefined")).toBeUndefined() + }) + + it("should clear all runtime config values", () => { + setRuntimeConfig("roo-cline", "setting1", "value1") + setRuntimeConfig("roo-cline", "setting2", "value2") + + clearRuntimeConfig() + + expect(getRuntimeConfig("roo-cline.setting1")).toBeUndefined() + expect(getRuntimeConfig("roo-cline.setting2")).toBeUndefined() + }) + + it("should return default value when no runtime config is set", () => { + const config = new MockWorkspaceConfiguration("roo-cline", context) + + expect(config.get("nonexistent", 0)).toBe(0) + expect(config.get("nonexistent", "default")).toBe("default") + }) + + it("should work with MockWorkspaceConfiguration.get() for CLI settings", () => { + // Simulate CLI setting commandExecutionTimeout + setRuntimeConfigValues("roo-cline", { + commandExecutionTimeout: 20, + commandTimeoutAllowlist: ["npm", "yarn"], + }) + + const config = new MockWorkspaceConfiguration("roo-cline", context) + + // These should return the runtime config values + expect(config.get("commandExecutionTimeout", 0)).toBe(20) + expect(config.get("commandTimeoutAllowlist", [])).toEqual(["npm", "yarn"]) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/logger.test.ts b/packages/vscode-shim/src/__tests__/logger.test.ts new file mode 100644 index 0000000000..56c0622480 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/logger.test.ts @@ -0,0 +1,198 @@ +import { logs, setLogger, type Logger } from "../utils/logger.js" + +describe("Logger", () => { + let originalEnv: string | undefined + let consoleSpy: { + log: ReturnType + warn: ReturnType + error: ReturnType + debug: ReturnType + } + + beforeEach(() => { + originalEnv = process.env.DEBUG + consoleSpy = { + log: vi.spyOn(console, "log").mockImplementation(() => {}), + warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + error: vi.spyOn(console, "error").mockImplementation(() => {}), + debug: vi.spyOn(console, "debug").mockImplementation(() => {}), + } + }) + + afterEach(() => { + process.env.DEBUG = originalEnv + vi.restoreAllMocks() + }) + + describe("logs object (default ConsoleLogger)", () => { + describe("info()", () => { + it("should log info message", () => { + logs.info("Info message") + + expect(consoleSpy.log).toHaveBeenCalled() + expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("Info message") + }) + + it("should include context in log", () => { + logs.info("Info message", "MyContext") + + expect(consoleSpy.log).toHaveBeenCalled() + expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("MyContext") + }) + + it("should use INFO as default context", () => { + logs.info("Info message") + + expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("INFO") + }) + }) + + describe("warn()", () => { + it("should log warning message", () => { + logs.warn("Warning message") + + expect(consoleSpy.warn).toHaveBeenCalled() + expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("Warning message") + }) + + it("should include context in warning", () => { + logs.warn("Warning message", "MyContext") + + expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("MyContext") + }) + + it("should use WARN as default context", () => { + logs.warn("Warning message") + + expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("WARN") + }) + }) + + describe("error()", () => { + it("should log error message", () => { + logs.error("Error message") + + expect(consoleSpy.error).toHaveBeenCalled() + expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("Error message") + }) + + it("should include context in error", () => { + logs.error("Error message", "MyContext") + + expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("MyContext") + }) + + it("should use ERROR as default context", () => { + logs.error("Error message") + + expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("ERROR") + }) + }) + + describe("debug()", () => { + it("should not log debug message when DEBUG env is not set", () => { + delete process.env.DEBUG + + logs.debug("Debug message") + + expect(consoleSpy.debug).not.toHaveBeenCalled() + }) + + it("should log debug message when DEBUG env is set", () => { + process.env.DEBUG = "true" + + logs.debug("Debug message") + + expect(consoleSpy.debug).toHaveBeenCalled() + expect(consoleSpy.debug.mock.calls[0]?.[0]).toContain("Debug message") + }) + + it("should include context in debug when DEBUG is set", () => { + process.env.DEBUG = "true" + + logs.debug("Debug message", "MyContext") + + expect(consoleSpy.debug.mock.calls[0]?.[0]).toContain("MyContext") + }) + }) + }) + + describe("setLogger()", () => { + it("should replace default logger with custom logger", () => { + const customLogger: Logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } + + setLogger(customLogger) + + logs.info("Test message", "TestContext") + + expect(customLogger.info).toHaveBeenCalledWith("Test message", "TestContext", undefined) + }) + + it("should use custom logger for all log levels", () => { + const customLogger: Logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } + + setLogger(customLogger) + + logs.info("Info") + logs.warn("Warn") + logs.error("Error") + logs.debug("Debug") + + expect(customLogger.info).toHaveBeenCalledTimes(1) + expect(customLogger.warn).toHaveBeenCalledTimes(1) + expect(customLogger.error).toHaveBeenCalledTimes(1) + expect(customLogger.debug).toHaveBeenCalledTimes(1) + }) + + it("should pass meta parameter to custom logger", () => { + const customLogger: Logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } + + setLogger(customLogger) + + const meta = { requestId: "123", userId: "456" } + logs.info("Info with meta", "Context", meta) + + expect(customLogger.info).toHaveBeenCalledWith("Info with meta", "Context", meta) + }) + }) + + describe("Logger interface", () => { + it("should accept custom logger implementing Logger interface", () => { + // Create a custom logger that collects messages + const messages: string[] = [] + const customLogger: Logger = { + info: (message) => messages.push(`INFO: ${message}`), + warn: (message) => messages.push(`WARN: ${message}`), + error: (message) => messages.push(`ERROR: ${message}`), + debug: (message) => messages.push(`DEBUG: ${message}`), + } + + setLogger(customLogger) + + logs.info("Test info") + logs.warn("Test warn") + logs.error("Test error") + logs.debug("Test debug") + + expect(messages).toContain("INFO: Test info") + expect(messages).toContain("WARN: Test warn") + expect(messages).toContain("ERROR: Test error") + expect(messages).toContain("DEBUG: Test debug") + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/machine-id.test.ts b/packages/vscode-shim/src/__tests__/machine-id.test.ts new file mode 100644 index 0000000000..45e91add05 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/machine-id.test.ts @@ -0,0 +1,143 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { machineIdSync } from "../utils/machine-id.js" + +describe("machineIdSync", () => { + let originalHome: string | undefined + let tempDir: string + + beforeEach(() => { + originalHome = process.env.HOME + tempDir = fs.mkdtempSync(path.join(tmpdir(), "machine-id-test-")) + process.env.HOME = tempDir + }) + + afterEach(() => { + process.env.HOME = originalHome + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + it("should generate a machine ID", () => { + const machineId = machineIdSync() + + expect(machineId).toBeDefined() + expect(typeof machineId).toBe("string") + expect(machineId.length).toBeGreaterThan(0) + }) + + it("should return a hexadecimal string", () => { + const machineId = machineIdSync() + + // SHA256 hash produces 64 hex characters + expect(machineId).toMatch(/^[a-f0-9]+$/) + expect(machineId.length).toBe(64) + }) + + it("should persist machine ID to file", () => { + const machineId = machineIdSync() + + const idPath = path.join(tempDir, ".vscode-mock", ".machine-id") + expect(fs.existsSync(idPath)).toBe(true) + + const storedId = fs.readFileSync(idPath, "utf-8").trim() + expect(storedId).toBe(machineId) + }) + + it("should return same ID on subsequent calls", () => { + const machineId1 = machineIdSync() + const machineId2 = machineIdSync() + + expect(machineId1).toBe(machineId2) + }) + + it("should read existing ID from file", () => { + // Create the directory and file first + const idDir = path.join(tempDir, ".vscode-mock") + const idPath = path.join(idDir, ".machine-id") + fs.mkdirSync(idDir, { recursive: true }) + fs.writeFileSync(idPath, "existing-machine-id-12345") + + const machineId = machineIdSync() + + expect(machineId).toBe("existing-machine-id-12345") + }) + + it("should create directory if it doesn't exist", () => { + const idDir = path.join(tempDir, ".vscode-mock") + + expect(fs.existsSync(idDir)).toBe(false) + + machineIdSync() + + expect(fs.existsSync(idDir)).toBe(true) + }) + + it("should handle missing HOME environment variable", () => { + // Use USERPROFILE instead (Windows fallback) + delete process.env.HOME + process.env.USERPROFILE = tempDir + + const machineId = machineIdSync() + + expect(machineId).toBeDefined() + expect(machineId.length).toBeGreaterThan(0) + + // Restore + process.env.HOME = tempDir + }) + + it("should generate unique IDs for different hosts", () => { + // This test verifies that the ID generation includes random data + // Since we can't easily change the hostname, we verify multiple generations + // in fresh environments produce unique results (due to random component) + + // First call generates and saves + const machineId1 = machineIdSync() + + // Delete the saved file to force regeneration + const idPath = path.join(tempDir, ".vscode-mock", ".machine-id") + fs.unlinkSync(idPath) + + // Second call should generate a new ID (random component) + const machineId2 = machineIdSync() + + // The IDs should be different due to the random component + expect(machineId1).not.toBe(machineId2) + }) + + it("should handle read errors gracefully", () => { + // Create an unreadable file (directory instead of file) + const idDir = path.join(tempDir, ".vscode-mock") + const idPath = path.join(idDir, ".machine-id") + fs.mkdirSync(idPath, { recursive: true }) // Create directory instead of file + + // Should not throw, should generate new ID + expect(() => machineIdSync()).not.toThrow() + + const machineId = machineIdSync() + expect(machineId).toBeDefined() + expect(machineId.length).toBeGreaterThan(0) + }) + + it("should handle write errors gracefully", () => { + // Make the directory read-only (Unix only) + if (process.platform !== "win32") { + const idDir = path.join(tempDir, ".vscode-mock") + fs.mkdirSync(idDir, { recursive: true }) + fs.chmodSync(idDir, 0o444) // Read-only + + // Should not throw, should still generate ID + expect(() => machineIdSync()).not.toThrow() + + const machineId = machineIdSync() + expect(machineId).toBeDefined() + + // Restore permissions for cleanup + fs.chmodSync(idDir, 0o755) + } + }) +}) diff --git a/packages/vscode-shim/src/__tests__/paths.test.ts b/packages/vscode-shim/src/__tests__/paths.test.ts new file mode 100644 index 0000000000..404d33bc9b --- /dev/null +++ b/packages/vscode-shim/src/__tests__/paths.test.ts @@ -0,0 +1,208 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { VSCodeMockPaths } from "../utils/paths.js" + +describe("VSCodeMockPaths", () => { + let originalHome: string | undefined + let tempDir: string + + beforeEach(() => { + originalHome = process.env.HOME + tempDir = fs.mkdtempSync(path.join(tmpdir(), "paths-test-")) + process.env.HOME = tempDir + }) + + afterEach(() => { + process.env.HOME = originalHome + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + describe("getGlobalStorageDir()", () => { + it("should return path containing .vscode-mock", () => { + const globalDir = VSCodeMockPaths.getGlobalStorageDir() + + expect(globalDir).toContain(".vscode-mock") + }) + + it("should return path containing global-storage", () => { + const globalDir = VSCodeMockPaths.getGlobalStorageDir() + + expect(globalDir).toContain("global-storage") + }) + + it("should use HOME environment variable", () => { + const globalDir = VSCodeMockPaths.getGlobalStorageDir() + + expect(globalDir).toContain(tempDir) + }) + + it("should return consistent path on multiple calls", () => { + const dir1 = VSCodeMockPaths.getGlobalStorageDir() + const dir2 = VSCodeMockPaths.getGlobalStorageDir() + + expect(dir1).toBe(dir2) + }) + }) + + describe("getWorkspaceStorageDir()", () => { + it("should return path containing .vscode-mock", () => { + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace") + + expect(workspaceDir).toContain(".vscode-mock") + }) + + it("should return path containing workspace-storage", () => { + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace") + + expect(workspaceDir).toContain("workspace-storage") + }) + + it("should include hashed workspace path", () => { + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace") + + // Should end with a hash (hex string) + const hash = path.basename(workspaceDir) + expect(hash).toMatch(/^[a-f0-9]+$/) + }) + + it("should return different paths for different workspaces", () => { + const dir1 = VSCodeMockPaths.getWorkspaceStorageDir("/workspace/one") + const dir2 = VSCodeMockPaths.getWorkspaceStorageDir("/workspace/two") + + expect(dir1).not.toBe(dir2) + }) + + it("should return same path for same workspace", () => { + const dir1 = VSCodeMockPaths.getWorkspaceStorageDir("/same/workspace") + const dir2 = VSCodeMockPaths.getWorkspaceStorageDir("/same/workspace") + + expect(dir1).toBe(dir2) + }) + + it("should handle Windows-style paths", () => { + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("C:\\Users\\test\\workspace") + + expect(workspaceDir).toContain("workspace-storage") + // Should still produce a valid hash + const hash = path.basename(workspaceDir) + expect(hash).toMatch(/^[a-f0-9]+$/) + }) + + it("should handle empty workspace path", () => { + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("") + + expect(workspaceDir).toContain("workspace-storage") + }) + }) + + describe("getLogsDir()", () => { + it("should return path containing .vscode-mock", () => { + const logsDir = VSCodeMockPaths.getLogsDir() + + expect(logsDir).toContain(".vscode-mock") + }) + + it("should return path containing logs", () => { + const logsDir = VSCodeMockPaths.getLogsDir() + + expect(logsDir).toContain("logs") + }) + + it("should return consistent path on multiple calls", () => { + const dir1 = VSCodeMockPaths.getLogsDir() + const dir2 = VSCodeMockPaths.getLogsDir() + + expect(dir1).toBe(dir2) + }) + }) + + describe("initializeWorkspace()", () => { + it("should create global storage directory", () => { + VSCodeMockPaths.initializeWorkspace("/test/workspace") + + const globalDir = VSCodeMockPaths.getGlobalStorageDir() + expect(fs.existsSync(globalDir)).toBe(true) + }) + + it("should create workspace storage directory", () => { + const workspacePath = "/test/workspace" + VSCodeMockPaths.initializeWorkspace(workspacePath) + + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir(workspacePath) + expect(fs.existsSync(workspaceDir)).toBe(true) + }) + + it("should create logs directory", () => { + VSCodeMockPaths.initializeWorkspace("/test/workspace") + + const logsDir = VSCodeMockPaths.getLogsDir() + expect(fs.existsSync(logsDir)).toBe(true) + }) + + it("should not fail if directories already exist", () => { + // Initialize twice + VSCodeMockPaths.initializeWorkspace("/test/workspace") + + expect(() => { + VSCodeMockPaths.initializeWorkspace("/test/workspace") + }).not.toThrow() + }) + + it("should create directories with correct structure", () => { + VSCodeMockPaths.initializeWorkspace("/test/workspace") + + const baseDir = path.join(tempDir, ".vscode-mock") + expect(fs.existsSync(baseDir)).toBe(true) + expect(fs.existsSync(path.join(baseDir, "global-storage"))).toBe(true) + expect(fs.existsSync(path.join(baseDir, "workspace-storage"))).toBe(true) + expect(fs.existsSync(path.join(baseDir, "logs"))).toBe(true) + }) + }) + + describe("hash consistency", () => { + it("should produce deterministic hashes", () => { + // The same workspace path should always produce the same hash + const workspace = "/project/my-project" + + const hash1 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace)) + const hash2 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace)) + const hash3 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace)) + + expect(hash1).toBe(hash2) + expect(hash2).toBe(hash3) + }) + + it("should handle special characters in workspace path", () => { + const workspaces = [ + "/path/with spaces/project", + "/path/with-dashes/project", + "/path/with_underscores/project", + "/path/with.dots/project", + ] + + for (const workspace of workspaces) { + const dir = VSCodeMockPaths.getWorkspaceStorageDir(workspace) + // Should produce valid directory name + expect(path.basename(dir)).toMatch(/^[a-f0-9]+$/) + } + }) + }) + + describe("USERPROFILE fallback (Windows)", () => { + it("should use USERPROFILE when HOME is not set", () => { + delete process.env.HOME + process.env.USERPROFILE = tempDir + + const globalDir = VSCodeMockPaths.getGlobalStorageDir() + + expect(globalDir).toContain(tempDir) + + // Restore for cleanup + process.env.HOME = tempDir + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/storage.test.ts b/packages/vscode-shim/src/__tests__/storage.test.ts new file mode 100644 index 0000000000..644911ffe1 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/storage.test.ts @@ -0,0 +1,178 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { FileMemento } from "../storage/Memento.js" +import { FileSecretStorage } from "../storage/SecretStorage.js" + +describe("FileMemento", () => { + let tempDir: string + let mementoPath: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "memento-test-")) + mementoPath = path.join(tempDir, "state.json") + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + it("should store and retrieve values", async () => { + const memento = new FileMemento(mementoPath) + + await memento.update("key1", "value1") + await memento.update("key2", 42) + + expect(memento.get("key1")).toBe("value1") + expect(memento.get("key2")).toBe(42) + }) + + it("should return default value when key doesn't exist", () => { + const memento = new FileMemento(mementoPath) + + expect(memento.get("nonexistent", "default")).toBe("default") + expect(memento.get("missing", 0)).toBe(0) + }) + + it("should persist data to file", async () => { + const memento1 = new FileMemento(mementoPath) + await memento1.update("persisted", "value") + + // Create new instance to verify persistence + const memento2 = new FileMemento(mementoPath) + expect(memento2.get("persisted")).toBe("value") + }) + + it("should delete values when updated with undefined", async () => { + const memento = new FileMemento(mementoPath) + + await memento.update("key", "value") + expect(memento.get("key")).toBe("value") + + await memento.update("key", undefined) + expect(memento.get("key")).toBeUndefined() + }) + + it("should return all keys", async () => { + const memento = new FileMemento(mementoPath) + + await memento.update("key1", "value1") + await memento.update("key2", "value2") + await memento.update("key3", "value3") + + const keys = memento.keys() + expect(keys).toHaveLength(3) + expect(keys).toContain("key1") + expect(keys).toContain("key2") + expect(keys).toContain("key3") + }) + + it("should clear all data", async () => { + const memento = new FileMemento(mementoPath) + + await memento.update("key1", "value1") + await memento.update("key2", "value2") + + memento.clear() + + expect(memento.keys()).toHaveLength(0) + expect(memento.get("key1")).toBeUndefined() + }) +}) + +describe("FileSecretStorage", () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "secrets-test-")) + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + it("should store and retrieve secrets", async () => { + const storage = new FileSecretStorage(tempDir) + + await storage.store("apiKey", "sk-test-123") + const retrieved = await storage.get("apiKey") + + expect(retrieved).toBe("sk-test-123") + }) + + it("should return undefined for non-existent secrets", async () => { + const storage = new FileSecretStorage(tempDir) + const result = await storage.get("nonexistent") + + expect(result).toBeUndefined() + }) + + it("should delete secrets", async () => { + const storage = new FileSecretStorage(tempDir) + + await storage.store("apiKey", "sk-test-123") + expect(await storage.get("apiKey")).toBe("sk-test-123") + + await storage.delete("apiKey") + expect(await storage.get("apiKey")).toBeUndefined() + }) + + it("should persist secrets across instances", async () => { + const storage1 = new FileSecretStorage(tempDir) + await storage1.store("token", "persistent-value") + + const storage2 = new FileSecretStorage(tempDir) + const value = await storage2.get("token") + + expect(value).toBe("persistent-value") + }) + + it("should fire onDidChange event when secret changes", async () => { + const storage = new FileSecretStorage(tempDir) + const events: string[] = [] + + storage.onDidChange((e) => { + events.push(e.key) + }) + + await storage.store("key1", "value1") + await storage.store("key2", "value2") + await storage.delete("key1") + + expect(events).toEqual(["key1", "key2", "key1"]) + }) + + it("should clear all secrets", async () => { + const storage = new FileSecretStorage(tempDir) + + await storage.store("key1", "value1") + await storage.store("key2", "value2") + + storage.clearAll() + + expect(await storage.get("key1")).toBeUndefined() + expect(await storage.get("key2")).toBeUndefined() + }) + + it("should create secrets.json file with restrictive permissions on Unix", async () => { + if (process.platform === "win32") { + // Skip on Windows + return + } + + const storage = new FileSecretStorage(tempDir) + await storage.store("key", "value") + + const secretsPath = path.join(tempDir, "secrets.json") + const stats = fs.statSync(secretsPath) + const mode = stats.mode & 0o777 + + // Should be 0600 (owner read/write only) + expect(mode).toBe(0o600) + }) +}) diff --git a/packages/vscode-shim/src/api/CommandsAPI.ts b/packages/vscode-shim/src/api/CommandsAPI.ts new file mode 100644 index 0000000000..0cd826f8d0 --- /dev/null +++ b/packages/vscode-shim/src/api/CommandsAPI.ts @@ -0,0 +1,181 @@ +/** + * CommandsAPI class for VSCode API + */ + +import { logs } from "../utils/logger.js" +import { Uri } from "../classes/Uri.js" +import { Position } from "../classes/Position.js" +import { Range } from "../classes/Range.js" +import { Selection } from "../classes/Selection.js" +import { ViewColumn, EndOfLine } from "../types.js" +import type { Thenable } from "../types.js" +import type { TextEditor, TextEditorEdit } from "../interfaces/editor.js" +import type { TextDocument } from "../interfaces/document.js" +import type { Disposable } from "../interfaces/workspace.js" +import type { WorkspaceAPI } from "./WorkspaceAPI.js" +import type { WindowAPI } from "./WindowAPI.js" + +/** + * Commands API mock for CLI mode + */ +export class CommandsAPI { + private commands: Map unknown> = new Map() + + registerCommand(command: string, callback: (...args: unknown[]) => unknown): Disposable { + this.commands.set(command, callback) + return { + dispose: () => { + this.commands.delete(command) + }, + } + } + + executeCommand(command: string, ...rest: unknown[]): Thenable { + const handler = this.commands.get(command) + if (handler) { + try { + const result = handler(...rest) + return Promise.resolve(result as T) + } catch (error) { + return Promise.reject(error) + } + } + + // Handle built-in commands + switch (command) { + case "workbench.action.files.saveFiles": + case "workbench.action.closeWindow": + case "workbench.action.reloadWindow": + return Promise.resolve(undefined as T) + case "vscode.diff": + // Simulate opening a diff view for the CLI + // The extension's DiffViewProvider expects this to create a diff editor + return this.handleDiffCommand( + rest[0] as Uri, + rest[1] as Uri, + rest[2] as string | undefined, + rest[3], + ) as Thenable + default: + logs.warn(`Unknown command: ${command}`, "VSCode.Commands") + return Promise.resolve(undefined as T) + } + } + + private async handleDiffCommand( + originalUri: Uri, + modifiedUri: Uri, + title?: string, + _options?: unknown, + ): Promise { + // The DiffViewProvider is waiting for the modified document to appear in visibleTextEditors + // We need to simulate this by opening the document and adding it to visible editors + + logs.info(`[DIFF] Handling vscode.diff command`, "VSCode.Commands", { + originalUri: originalUri?.toString(), + modifiedUri: modifiedUri?.toString(), + title, + }) + + if (!modifiedUri) { + logs.warn("[DIFF] vscode.diff called without modified URI", "VSCode.Commands") + return + } + + // Get the workspace API to open the document + const workspace = (global as unknown as { vscode?: { workspace?: WorkspaceAPI } }).vscode?.workspace + const window = (global as unknown as { vscode?: { window?: WindowAPI } }).vscode?.window + + if (!workspace || !window) { + logs.warn("[DIFF] VSCode APIs not available for diff command", "VSCode.Commands") + return + } + + logs.info( + `[DIFF] Current visibleTextEditors count: ${window.visibleTextEditors?.length || 0}`, + "VSCode.Commands", + ) + + try { + // The document should already be open from the showTextDocument call + // Find it in the existing textDocuments + logs.info(`[DIFF] Looking for already-opened document: ${modifiedUri.fsPath}`, "VSCode.Commands") + let document = workspace.textDocuments.find((doc: TextDocument) => doc.uri.fsPath === modifiedUri.fsPath) + + if (!document) { + // If not found, open it now + logs.info(`[DIFF] Document not found, opening: ${modifiedUri.fsPath}`, "VSCode.Commands") + document = await workspace.openTextDocument(modifiedUri) + logs.info(`[DIFF] Document opened successfully, lineCount: ${document.lineCount}`, "VSCode.Commands") + } else { + logs.info(`[DIFF] Found existing document, lineCount: ${document.lineCount}`, "VSCode.Commands") + } + + // Create a mock editor for the diff view + const mockEditor: TextEditor = { + document, + selection: new Selection(new Position(0, 0), new Position(0, 0)), + selections: [new Selection(new Position(0, 0), new Position(0, 0))], + visibleRanges: [new Range(new Position(0, 0), new Position(0, 0))], + options: {}, + viewColumn: ViewColumn.One, + edit: async (callback: (editBuilder: TextEditorEdit) => void) => { + // Create a mock edit builder + const editBuilder: TextEditorEdit = { + replace: (_range: Range | Position | Selection, _text: string) => { + // In CLI mode, we don't actually edit here + // The DiffViewProvider will handle the actual edits + logs.debug("Mock edit builder replace called", "VSCode.Commands") + }, + insert: (_position: Position, _text: string) => { + logs.debug("Mock edit builder insert called", "VSCode.Commands") + }, + delete: (_range: Range | Selection) => { + logs.debug("Mock edit builder delete called", "VSCode.Commands") + }, + setEndOfLine: (_endOfLine: EndOfLine) => { + logs.debug("Mock edit builder setEndOfLine called", "VSCode.Commands") + }, + } + callback(editBuilder) + return true + }, + insertSnippet: () => Promise.resolve(true), + setDecorations: () => {}, + revealRange: () => {}, + show: () => {}, + hide: () => {}, + } + + // Add the editor to visible editors + if (!window.visibleTextEditors) { + window.visibleTextEditors = [] + } + + // Check if this editor is already in visibleTextEditors (from showTextDocument) + const existingEditor = window.visibleTextEditors.find( + (e: TextEditor) => e.document.uri.fsPath === modifiedUri.fsPath, + ) + + if (existingEditor) { + logs.info(`[DIFF] Editor already in visibleTextEditors, updating it`, "VSCode.Commands") + // Update the existing editor with the mock editor properties + Object.assign(existingEditor, mockEditor) + } else { + logs.info(`[DIFF] Adding new mock editor to visibleTextEditors`, "VSCode.Commands") + window.visibleTextEditors.push(mockEditor) + } + + logs.info(`[DIFF] visibleTextEditors count: ${window.visibleTextEditors.length}`, "VSCode.Commands") + + // The onDidChangeVisibleTextEditors event was already fired by showTextDocument + // We don't need to fire it again here + logs.info( + `[DIFF] Diff view simulation complete (events already fired by showTextDocument)`, + "VSCode.Commands", + ) + } catch (error) { + logs.error("[DIFF] Error simulating diff view", "VSCode.Commands", { error }) + } + } +} diff --git a/packages/vscode-shim/src/api/FileSystemAPI.ts b/packages/vscode-shim/src/api/FileSystemAPI.ts new file mode 100644 index 0000000000..78358fa938 --- /dev/null +++ b/packages/vscode-shim/src/api/FileSystemAPI.ts @@ -0,0 +1,77 @@ +/** + * FileSystemAPI class for VSCode API + */ + +import * as fs from "fs" +import * as path from "path" +import { Uri } from "../classes/Uri.js" +import { FileSystemError } from "../classes/Additional.js" +import { ensureDirectoryExists } from "../utils/paths.js" +import type { FileStat } from "../types.js" + +/** + * File system API mock for CLI mode + * Provides file operations using Node.js fs module + */ +export class FileSystemAPI { + async stat(uri: Uri): Promise { + try { + const stats = fs.statSync(uri.fsPath) + return { + type: stats.isDirectory() ? 2 : 1, // Directory = 2, File = 1 + ctime: stats.ctimeMs, + mtime: stats.mtimeMs, + size: stats.size, + } + } catch { + // If file doesn't exist, assume it's a file for CLI purposes + return { + type: 1, // File + ctime: Date.now(), + mtime: Date.now(), + size: 0, + } + } + } + + async readFile(uri: Uri): Promise { + try { + const content = fs.readFileSync(uri.fsPath) + return new Uint8Array(content) + } catch (error) { + // Check if it's a file not found error (ENOENT) + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw FileSystemError.FileNotFound(uri) + } + // For other errors, throw a generic FileSystemError + throw new FileSystemError(`Failed to read file: ${uri.fsPath}`) + } + } + + async writeFile(uri: Uri, content: Uint8Array): Promise { + try { + // Ensure directory exists + const dir = path.dirname(uri.fsPath) + ensureDirectoryExists(dir) + fs.writeFileSync(uri.fsPath, content) + } catch { + throw new Error(`Failed to write file: ${uri.fsPath}`) + } + } + + async delete(uri: Uri): Promise { + try { + fs.unlinkSync(uri.fsPath) + } catch { + throw new Error(`Failed to delete file: ${uri.fsPath}`) + } + } + + async createDirectory(uri: Uri): Promise { + try { + fs.mkdirSync(uri.fsPath, { recursive: true }) + } catch { + throw new Error(`Failed to create directory: ${uri.fsPath}`) + } + } +} diff --git a/packages/vscode-shim/src/api/TabGroupsAPI.ts b/packages/vscode-shim/src/api/TabGroupsAPI.ts new file mode 100644 index 0000000000..cba318b433 --- /dev/null +++ b/packages/vscode-shim/src/api/TabGroupsAPI.ts @@ -0,0 +1,69 @@ +/** + * TabGroupsAPI class for VSCode API + */ + +import { EventEmitter } from "../classes/EventEmitter.js" +import type { Uri } from "../classes/Uri.js" +import type { Disposable } from "../interfaces/workspace.js" + +/** + * Tab interface representing an open tab + */ +export interface Tab { + input: TabInputText | unknown + label: string + isActive: boolean + isDirty: boolean +} + +/** + * Tab input for text files + */ +export interface TabInputText { + uri: Uri +} + +/** + * Tab group interface + */ +export interface TabGroup { + tabs: Tab[] +} + +/** + * Tab groups API mock for CLI mode + */ +export class TabGroupsAPI { + private _onDidChangeTabs = new EventEmitter() + private _tabGroups: TabGroup[] = [] + + get all(): TabGroup[] { + return this._tabGroups + } + + onDidChangeTabs(listener: () => void): Disposable { + return this._onDidChangeTabs.event(listener) + } + + async close(tab: Tab): Promise { + // Find and remove the tab from all groups + for (const group of this._tabGroups) { + const index = group.tabs.indexOf(tab) + if (index !== -1) { + group.tabs.splice(index, 1) + this._onDidChangeTabs.fire() + return true + } + } + return false + } + + // Internal method to simulate tab changes for CLI + _simulateTabChange(): void { + this._onDidChangeTabs.fire() + } + + dispose(): void { + this._onDidChangeTabs.dispose() + } +} diff --git a/packages/vscode-shim/src/api/WindowAPI.ts b/packages/vscode-shim/src/api/WindowAPI.ts new file mode 100644 index 0000000000..631a8e6a3a --- /dev/null +++ b/packages/vscode-shim/src/api/WindowAPI.ts @@ -0,0 +1,362 @@ +/** + * WindowAPI class for VSCode API + */ + +import { logs } from "../utils/logger.js" +import { Uri } from "../classes/Uri.js" +import { Position } from "../classes/Position.js" +import { Range } from "../classes/Range.js" +import { Selection } from "../classes/Selection.js" +import { EventEmitter } from "../classes/EventEmitter.js" +import { ThemeIcon } from "../classes/Additional.js" +import { OutputChannel } from "../classes/OutputChannel.js" +import { StatusBarItem } from "../classes/StatusBarItem.js" +import { TextEditorDecorationType } from "../classes/TextEditorDecorationType.js" +import { TabGroupsAPI } from "./TabGroupsAPI.js" +import { StatusBarAlignment, ViewColumn } from "../types.js" +import type { WorkspaceAPI } from "./WorkspaceAPI.js" +import type { Thenable } from "../types.js" +import type { + TextEditor, + TextEditorSelectionChangeEvent, + TextDocumentShowOptions, + DecorationRenderOptions, +} from "../interfaces/editor.js" +import type { TextDocument } from "../interfaces/document.js" +import type { Terminal, TerminalDimensionsChangeEvent, TerminalDataWriteEvent } from "../interfaces/terminal.js" +import type { + WebviewViewProvider, + WebviewView, + Webview, + ViewBadge, + WebviewViewProviderOptions, + UriHandler, +} from "../interfaces/webview.js" +import type { QuickPickOptions, InputBoxOptions, OpenDialogOptions, Disposable } from "../interfaces/workspace.js" +import type { CancellationToken } from "../interfaces/document.js" + +/** + * Window API mock for CLI mode + */ +export class WindowAPI { + public tabGroups: TabGroupsAPI + public visibleTextEditors: TextEditor[] = [] + public _onDidChangeVisibleTextEditors = new EventEmitter() + private _workspace?: WorkspaceAPI + private static _decorationCounter = 0 + + constructor() { + this.tabGroups = new TabGroupsAPI() + } + + setWorkspace(workspace: WorkspaceAPI) { + this._workspace = workspace + } + + createOutputChannel(name: string): OutputChannel { + return new OutputChannel(name) + } + + createStatusBarItem(alignment?: StatusBarAlignment, priority?: number): StatusBarItem + createStatusBarItem(id?: string, alignment?: StatusBarAlignment, priority?: number): StatusBarItem + createStatusBarItem( + idOrAlignment?: string | StatusBarAlignment, + alignmentOrPriority?: StatusBarAlignment | number, + priority?: number, + ): StatusBarItem { + // Handle overloaded signatures + let actualAlignment: StatusBarAlignment + let actualPriority: number | undefined + + if (typeof idOrAlignment === "string") { + // Called with id, alignment, priority + actualAlignment = (alignmentOrPriority as StatusBarAlignment) ?? StatusBarAlignment.Left + actualPriority = priority + } else { + // Called with alignment, priority + actualAlignment = (idOrAlignment as StatusBarAlignment) ?? StatusBarAlignment.Left + actualPriority = alignmentOrPriority as number | undefined + } + + return new StatusBarItem(actualAlignment, actualPriority) + } + + createTextEditorDecorationType(_options: DecorationRenderOptions): TextEditorDecorationType { + return new TextEditorDecorationType(`decoration-${++WindowAPI._decorationCounter}`) + } + + createTerminal(options?: { + name?: string + shellPath?: string + shellArgs?: string[] + cwd?: string + env?: { [key: string]: string | null | undefined } + iconPath?: ThemeIcon + hideFromUser?: boolean + message?: string + strictEnv?: boolean + }): Terminal { + // Return a mock terminal object + return { + name: options?.name || "Terminal", + processId: Promise.resolve(undefined), + creationOptions: options || {}, + exitStatus: undefined, + state: { isInteractedWith: false }, + sendText: (text: string, _addNewLine?: boolean) => { + logs.debug(`Terminal sendText: ${text}`, "VSCode.Terminal") + }, + show: (_preserveFocus?: boolean) => { + logs.debug("Terminal show called", "VSCode.Terminal") + }, + hide: () => { + logs.debug("Terminal hide called", "VSCode.Terminal") + }, + dispose: () => { + logs.debug("Terminal disposed", "VSCode.Terminal") + }, + } + } + + showInformationMessage(message: string, ..._items: string[]): Thenable { + logs.info(message, "VSCode.Window") + return Promise.resolve(undefined) + } + + showWarningMessage(message: string, ..._items: string[]): Thenable { + logs.warn(message, "VSCode.Window") + return Promise.resolve(undefined) + } + + showErrorMessage(message: string, ..._items: string[]): Thenable { + logs.error(message, "VSCode.Window") + return Promise.resolve(undefined) + } + + showQuickPick(items: string[], _options?: QuickPickOptions): Thenable { + // Return first item for CLI + return Promise.resolve(items[0]) + } + + showInputBox(_options?: InputBoxOptions): Thenable { + // Return empty string for CLI + return Promise.resolve("") + } + + showOpenDialog(_options?: OpenDialogOptions): Thenable { + // Return empty array for CLI + return Promise.resolve([]) + } + + async showTextDocument( + documentOrUri: TextDocument | Uri, + columnOrOptions?: ViewColumn | TextDocumentShowOptions, + _preserveFocus?: boolean, + ): Promise { + // Mock implementation for CLI + // In a real VSCode environment, this would open the document in an editor + const uri = documentOrUri instanceof Uri ? documentOrUri : documentOrUri.uri + logs.debug(`showTextDocument called for: ${uri?.toString() || "unknown"}`, "VSCode.Window") + + // Create a placeholder editor first so it's in visibleTextEditors when onDidOpenTextDocument fires + const placeholderEditor: TextEditor = { + document: { uri } as TextDocument, + selection: new Selection(new Position(0, 0), new Position(0, 0)), + selections: [new Selection(new Position(0, 0), new Position(0, 0))], + visibleRanges: [new Range(new Position(0, 0), new Position(0, 0))], + options: {}, + viewColumn: typeof columnOrOptions === "number" ? columnOrOptions : ViewColumn.One, + edit: () => Promise.resolve(true), + insertSnippet: () => Promise.resolve(true), + setDecorations: () => {}, + revealRange: () => {}, + show: () => {}, + hide: () => {}, + } + + // Add placeholder to visible editors BEFORE opening document + this.visibleTextEditors.push(placeholderEditor) + logs.debug( + `Placeholder editor added to visibleTextEditors, total: ${this.visibleTextEditors.length}`, + "VSCode.Window", + ) + + // If we have a URI, open the document (this will fire onDidOpenTextDocument) + let document: TextDocument | Uri = documentOrUri + if (documentOrUri instanceof Uri && this._workspace) { + logs.debug("Opening document via workspace.openTextDocument", "VSCode.Window") + document = await this._workspace.openTextDocument(uri) + logs.debug("Document opened successfully", "VSCode.Window") + + // Update the placeholder editor with the real document + placeholderEditor.document = document + } + + // Fire events immediately using setImmediate + setImmediate(() => { + logs.debug("Firing onDidChangeVisibleTextEditors event", "VSCode.Window") + this._onDidChangeVisibleTextEditors.fire(this.visibleTextEditors) + logs.debug("onDidChangeVisibleTextEditors event fired", "VSCode.Window") + }) + + logs.debug("Returning editor from showTextDocument", "VSCode.Window") + return placeholderEditor + } + + registerWebviewViewProvider( + viewId: string, + provider: WebviewViewProvider, + _options?: WebviewViewProviderOptions, + ): Disposable { + // Store the provider for later use by ExtensionHost + if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) { + const extensionHost = ( + global as unknown as { + __extensionHost: { + registerWebviewProvider: (viewId: string, provider: WebviewViewProvider) => void + isInInitialSetup: () => boolean + markWebviewReady: () => void + } + } + ).__extensionHost + extensionHost.registerWebviewProvider(viewId, provider) + + // Set up webview mock that captures messages from the extension + const mockWebview = { + postMessage: (message: unknown): Thenable => { + // Forward extension messages to ExtensionHost for CLI consumption + if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) { + ;( + global as unknown as { + __extensionHost: { emit: (event: string, message: unknown) => void } + } + ).__extensionHost.emit("extensionWebviewMessage", message) + } + return Promise.resolve(true) + }, + onDidReceiveMessage: (listener: (message: unknown) => void) => { + // This is how the extension listens for messages from the webview + // We need to connect this to our message bridge + if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) { + ;( + global as unknown as { + __extensionHost: { on: (event: string, listener: (message: unknown) => void) => void } + } + ).__extensionHost.on("webviewMessage", listener) + } + return { dispose: () => {} } + }, + asWebviewUri: (uriArg: Uri) => { + // Convert file URIs to webview-compatible URIs + // For CLI, we can just return a mock webview URI + return Uri.parse(`vscode-webview://webview/${uriArg.path}`) + }, + html: "", + options: {}, + cspSource: "vscode-webview:", + } + + // Provide the mock webview to the provider + if (provider.resolveWebviewView) { + const mockWebviewView = { + webview: mockWebview as Webview, + viewType: viewId, + title: viewId, + description: undefined as string | undefined, + badge: undefined as ViewBadge | undefined, + show: () => {}, + onDidChangeVisibility: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + visible: true, + } + + // Call resolveWebviewView immediately with initialization context + // No setTimeout needed - use event-based synchronization instead + ;(async () => { + try { + // Pass isInitialSetup flag in context to prevent task abortion + const context = { + preserveFocus: false, + isInitialSetup: extensionHost.isInInitialSetup(), + } + + logs.debug( + `Calling resolveWebviewView with isInitialSetup=${context.isInitialSetup}`, + "VSCode.Window", + ) + + // Await the result to ensure webview is fully initialized before marking ready + await provider.resolveWebviewView(mockWebviewView as WebviewView, {}, {} as CancellationToken) + + // Mark webview as ready after resolution completes + extensionHost.markWebviewReady() + logs.debug("Webview resolution complete, marked as ready", "VSCode.Window") + } catch (error) { + logs.error("Error resolving webview view", "VSCode.Window", { error }) + } + })() + } + } + return { + dispose: () => { + if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) { + ;( + global as unknown as { + __extensionHost: { unregisterWebviewProvider: (viewId: string) => void } + } + ).__extensionHost.unregisterWebviewProvider(viewId) + } + }, + } + } + + registerUriHandler(_handler: UriHandler): Disposable { + // Store the URI handler for later use + return { + dispose: () => {}, + } + } + + onDidChangeTextEditorSelection(listener: (event: TextEditorSelectionChangeEvent) => void): Disposable { + const emitter = new EventEmitter() + return emitter.event(listener) + } + + onDidChangeActiveTextEditor(listener: (event: TextEditor | undefined) => void): Disposable { + const emitter = new EventEmitter() + return emitter.event(listener) + } + + onDidChangeVisibleTextEditors(listener: (editors: TextEditor[]) => void): Disposable { + return this._onDidChangeVisibleTextEditors.event(listener) + } + + // Terminal event handlers + onDidCloseTerminal(_listener: (terminal: Terminal) => void): Disposable { + return { dispose: () => {} } + } + + onDidOpenTerminal(_listener: (terminal: Terminal) => void): Disposable { + return { dispose: () => {} } + } + + onDidChangeActiveTerminal(_listener: (terminal: Terminal | undefined) => void): Disposable { + return { dispose: () => {} } + } + + onDidChangeTerminalDimensions(_listener: (event: TerminalDimensionsChangeEvent) => void): Disposable { + return { dispose: () => {} } + } + + onDidWriteTerminalData(_listener: (event: TerminalDataWriteEvent) => void): Disposable { + return { dispose: () => {} } + } + + get activeTerminal(): Terminal | undefined { + return undefined + } + + get terminals(): Terminal[] { + return [] + } +} diff --git a/packages/vscode-shim/src/api/WorkspaceAPI.ts b/packages/vscode-shim/src/api/WorkspaceAPI.ts new file mode 100644 index 0000000000..2c00d7b529 --- /dev/null +++ b/packages/vscode-shim/src/api/WorkspaceAPI.ts @@ -0,0 +1,322 @@ +/** + * WorkspaceAPI class for VSCode API + */ + +import * as fs from "fs" +import * as path from "path" +import { logs } from "../utils/logger.js" +import { Uri } from "../classes/Uri.js" +import { Position } from "../classes/Position.js" +import { Range } from "../classes/Range.js" +import { EventEmitter } from "../classes/EventEmitter.js" +import { WorkspaceEdit } from "../classes/TextEdit.js" +import { FileSystemAPI } from "./FileSystemAPI.js" +import { MockWorkspaceConfiguration } from "./WorkspaceConfiguration.js" +import type { ExtensionContextImpl } from "../context/ExtensionContext.js" +import type { + TextDocument, + TextLine, + WorkspaceFoldersChangeEvent, + WorkspaceFolder, + TextDocumentChangeEvent, + ConfigurationChangeEvent, + TextDocumentContentProvider, + FileSystemWatcher, + RelativePattern, +} from "../interfaces/document.js" +import type { Disposable, WorkspaceConfiguration } from "../interfaces/workspace.js" +import type { Thenable } from "../types.js" + +/** + * Workspace API mock for CLI mode + */ +export class WorkspaceAPI { + public workspaceFolders: WorkspaceFolder[] | undefined + public name: string | undefined + public workspaceFile: Uri | undefined + public fs: FileSystemAPI + public textDocuments: TextDocument[] = [] + private _onDidChangeWorkspaceFolders = new EventEmitter() + private _onDidOpenTextDocument = new EventEmitter() + private _onDidChangeTextDocument = new EventEmitter() + private _onDidCloseTextDocument = new EventEmitter() + private context: ExtensionContextImpl + + constructor(workspacePath: string, context: ExtensionContextImpl) { + this.context = context + this.workspaceFolders = [ + { + uri: Uri.file(workspacePath), + name: path.basename(workspacePath), + index: 0, + }, + ] + this.name = path.basename(workspacePath) + this.fs = new FileSystemAPI() + } + + asRelativePath(pathOrUri: string | Uri, includeWorkspaceFolder?: boolean): string { + const fsPath = typeof pathOrUri === "string" ? pathOrUri : pathOrUri.fsPath + + // If no workspace folders, return the original path + if (!this.workspaceFolders || this.workspaceFolders.length === 0) { + return fsPath + } + + // Try to find a workspace folder that contains this path + for (const folder of this.workspaceFolders) { + const workspacePath = folder.uri.fsPath + + // Normalize paths for comparison (handle different path separators) + const normalizedFsPath = path.normalize(fsPath) + const normalizedWorkspacePath = path.normalize(workspacePath) + + // Check if the path is within this workspace folder + if (normalizedFsPath.startsWith(normalizedWorkspacePath)) { + // Get the relative path + let relativePath = path.relative(normalizedWorkspacePath, normalizedFsPath) + + // If includeWorkspaceFolder is true and there are multiple workspace folders, + // prepend the workspace folder name + if (includeWorkspaceFolder && this.workspaceFolders.length > 1) { + relativePath = path.join(folder.name, relativePath) + } + + return relativePath + } + } + + // If not within any workspace folder, return the original path + return fsPath + } + + onDidChangeWorkspaceFolders(listener: (event: WorkspaceFoldersChangeEvent) => void): Disposable { + return this._onDidChangeWorkspaceFolders.event(listener) + } + + onDidChangeConfiguration(listener: (event: ConfigurationChangeEvent) => void): Disposable { + // Create a mock configuration change event emitter + const emitter = new EventEmitter() + return emitter.event(listener) + } + + onDidChangeTextDocument(listener: (event: TextDocumentChangeEvent) => void): Disposable { + return this._onDidChangeTextDocument.event(listener) + } + + onDidOpenTextDocument(listener: (event: TextDocument) => void): Disposable { + logs.debug("Registering onDidOpenTextDocument listener", "VSCode.Workspace") + return this._onDidOpenTextDocument.event(listener) + } + + onDidCloseTextDocument(listener: (event: TextDocument) => void): Disposable { + return this._onDidCloseTextDocument.event(listener) + } + + getConfiguration(section?: string): WorkspaceConfiguration { + return new MockWorkspaceConfiguration(section, this.context) + } + + findFiles(_include: string, _exclude?: string): Thenable { + // Basic implementation - could be enhanced with glob patterns + return Promise.resolve([]) + } + + async openTextDocument(uri: Uri): Promise { + logs.debug(`openTextDocument called for: ${uri.fsPath}`, "VSCode.Workspace") + + // Read file content + let content = "" + try { + content = fs.readFileSync(uri.fsPath, "utf-8") + logs.debug(`File content read successfully, length: ${content.length}`, "VSCode.Workspace") + } catch (error) { + logs.warn(`Failed to read file: ${uri.fsPath}`, "VSCode.Workspace", { error }) + } + + const lines = content.split("\n") + const document: TextDocument = { + uri, + fileName: uri.fsPath, + languageId: "plaintext", + version: 1, + isDirty: false, + isClosed: false, + lineCount: lines.length, + getText: (range?: Range) => { + if (!range) { + return content + } + return lines.slice(range.start.line, range.end.line + 1).join("\n") + }, + lineAt: (line: number): TextLine => { + const text = lines[line] || "" + return { + text, + range: new Range(new Position(line, 0), new Position(line, text.length)), + rangeIncludingLineBreak: new Range(new Position(line, 0), new Position(line + 1, 0)), + firstNonWhitespaceCharacterIndex: text.search(/\S/), + isEmptyOrWhitespace: text.trim().length === 0, + } + }, + offsetAt: (position: Position) => { + let offset = 0 + for (let i = 0; i < position.line && i < lines.length; i++) { + offset += (lines[i]?.length || 0) + 1 // +1 for newline + } + offset += position.character + return offset + }, + positionAt: (offset: number) => { + let currentOffset = 0 + for (let i = 0; i < lines.length; i++) { + const lineLength = (lines[i]?.length || 0) + 1 // +1 for newline + if (currentOffset + lineLength > offset) { + return new Position(i, offset - currentOffset) + } + currentOffset += lineLength + } + return new Position(lines.length - 1, lines[lines.length - 1]?.length || 0) + }, + save: () => Promise.resolve(true), + validateRange: (range: Range) => range, + validatePosition: (position: Position) => position, + } + + // Add to textDocuments array + this.textDocuments.push(document) + logs.debug(`Document added to textDocuments array, total: ${this.textDocuments.length}`, "VSCode.Workspace") + + // Fire the event after a small delay to ensure listeners are fully registered + logs.debug("Waiting before firing onDidOpenTextDocument", "VSCode.Workspace") + await new Promise((resolve) => setTimeout(resolve, 10)) + logs.debug("Firing onDidOpenTextDocument event", "VSCode.Workspace") + this._onDidOpenTextDocument.fire(document) + logs.debug("onDidOpenTextDocument event fired", "VSCode.Workspace") + + return document + } + + async applyEdit(edit: WorkspaceEdit): Promise { + // In CLI mode, we need to apply the edits to the actual files + try { + for (const [uri, edits] of edit.entries()) { + let filePath = uri.fsPath + + // On Windows, strip leading slash if present (e.g., /C:/path becomes C:/path) + if (process.platform === "win32" && filePath.startsWith("/")) { + filePath = filePath.slice(1) + } + + let content = "" + + // Read existing content if file exists + try { + content = fs.readFileSync(filePath, "utf-8") + } catch { + // File doesn't exist, start with empty content + } + + // Apply edits in reverse order to maintain correct positions + const sortedEdits = edits.sort((a, b) => { + const lineDiff = b.range.start.line - a.range.start.line + if (lineDiff !== 0) return lineDiff + return b.range.start.character - a.range.start.character + }) + + const lines = content.split("\n") + for (const textEdit of sortedEdits) { + const startLine = textEdit.range.start.line + const startChar = textEdit.range.start.character + const endLine = textEdit.range.end.line + const endChar = textEdit.range.end.character + + if (startLine === endLine) { + // Single line edit + const line = lines[startLine] || "" + lines[startLine] = line.substring(0, startChar) + textEdit.newText + line.substring(endChar) + } else { + // Multi-line edit + const firstLine = lines[startLine] || "" + const lastLine = lines[endLine] || "" + const newContent = + firstLine.substring(0, startChar) + textEdit.newText + lastLine.substring(endChar) + lines.splice(startLine, endLine - startLine + 1, newContent) + } + } + + // Write back to file + const newContent = lines.join("\n") + fs.writeFileSync(filePath, newContent, "utf-8") + + // Update the in-memory document object to reflect the new content + // This is critical for CLI mode where DiffViewProvider reads from the document object + const document = this.textDocuments.find((doc: TextDocument) => doc.uri.fsPath === filePath) + if (document) { + const newLines = newContent.split("\n") + + // Update document properties with new content + document.lineCount = newLines.length + document.getText = (range?: Range) => { + if (!range) { + return newContent + } + return newLines.slice(range.start.line, range.end.line + 1).join("\n") + } + document.lineAt = (line: number): TextLine => { + const text = newLines[line] || "" + return { + text, + range: new Range(new Position(line, 0), new Position(line, text.length)), + rangeIncludingLineBreak: new Range(new Position(line, 0), new Position(line + 1, 0)), + firstNonWhitespaceCharacterIndex: text.search(/\S/), + isEmptyOrWhitespace: text.trim().length === 0, + } + } + document.offsetAt = (position: Position) => { + let offset = 0 + for (let i = 0; i < position.line && i < newLines.length; i++) { + offset += (newLines[i]?.length || 0) + 1 // +1 for newline + } + offset += position.character + return offset + } + document.positionAt = (offset: number) => { + let currentOffset = 0 + for (let i = 0; i < newLines.length; i++) { + const lineLength = (newLines[i]?.length || 0) + 1 // +1 for newline + if (currentOffset + lineLength > offset) { + return new Position(i, offset - currentOffset) + } + currentOffset += lineLength + } + return new Position(newLines.length - 1, newLines[newLines.length - 1]?.length || 0) + } + } + } + return true + } catch (error) { + logs.error("Failed to apply workspace edit", "VSCode.Workspace", { error }) + return false + } + } + + createFileSystemWatcher( + _globPattern?: string | RelativePattern, + _ignoreCreateEvents?: boolean, + _ignoreChangeEvents?: boolean, + _ignoreDeleteEvents?: boolean, + ): FileSystemWatcher { + const emitter = new EventEmitter() + return { + onDidChange: (listener: (e: Uri) => void) => emitter.event(listener), + onDidCreate: (listener: (e: Uri) => void) => emitter.event(listener), + onDidDelete: (listener: (e: Uri) => void) => emitter.event(listener), + dispose: () => emitter.dispose(), + } + } + + registerTextDocumentContentProvider(_scheme: string, _provider: TextDocumentContentProvider): Disposable { + return { dispose: () => {} } + } +} diff --git a/packages/vscode-shim/src/api/WorkspaceConfiguration.ts b/packages/vscode-shim/src/api/WorkspaceConfiguration.ts new file mode 100644 index 0000000000..33dbc9c7b2 --- /dev/null +++ b/packages/vscode-shim/src/api/WorkspaceConfiguration.ts @@ -0,0 +1,195 @@ +/** + * MockWorkspaceConfiguration class for VSCode API + */ + +import * as path from "path" +import { logs } from "../utils/logger.js" +import { VSCodeMockPaths, ensureDirectoryExists } from "../utils/paths.js" +import { FileMemento } from "../storage/Memento.js" +import { ConfigurationTarget } from "../types.js" +import type { ConfigurationInspect } from "../types.js" +import type { WorkspaceConfiguration } from "../interfaces/workspace.js" +import type { ExtensionContextImpl } from "../context/ExtensionContext.js" + +/** + * In-memory runtime configuration store shared across all MockWorkspaceConfiguration instances. + * This allows configuration to be updated at runtime (e.g., from CLI settings) without + * persisting to disk. Values in this store take precedence over disk-based mementos. + */ +const runtimeConfig: Map = new Map() + +/** + * Set a runtime configuration value. + * @param section The configuration section (e.g., "roo-cline") + * @param key The configuration key (e.g., "commandExecutionTimeout") + * @param value The value to set + */ +export function setRuntimeConfig(section: string, key: string, value: unknown): void { + const fullKey = `${section}.${key}` + runtimeConfig.set(fullKey, value) + logs.debug(`Runtime config set: ${fullKey} = ${JSON.stringify(value)}`, "VSCode.MockWorkspaceConfiguration") +} + +/** + * Set multiple runtime configuration values at once. + * @param section The configuration section (e.g., "roo-cline") + * @param values Object containing key-value pairs to set + */ +export function setRuntimeConfigValues(section: string, values: Record): void { + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) { + setRuntimeConfig(section, key, value) + } + } +} + +/** + * Clear all runtime configuration values. + */ +export function clearRuntimeConfig(): void { + runtimeConfig.clear() + logs.debug("Runtime config cleared", "VSCode.MockWorkspaceConfiguration") +} + +/** + * Get a runtime configuration value. + * @param fullKey The full configuration key (e.g., "roo-cline.commandExecutionTimeout") + * @returns The value or undefined if not set + */ +export function getRuntimeConfig(fullKey: string): unknown { + return runtimeConfig.get(fullKey) +} + +/** + * Mock workspace configuration for CLI mode + * Persists configuration to JSON files + */ +export class MockWorkspaceConfiguration implements WorkspaceConfiguration { + private section: string | undefined + private globalMemento: FileMemento + private workspaceMemento: FileMemento + + constructor(section?: string, context?: ExtensionContextImpl) { + this.section = section + + if (context) { + // Use the extension context's mementos + this.globalMemento = context.globalState as unknown as FileMemento + this.workspaceMemento = context.workspaceState as unknown as FileMemento + } else { + // Fallback: create our own mementos (shouldn't happen in normal usage) + const globalStoragePath = VSCodeMockPaths.getGlobalStorageDir() + const workspaceStoragePath = VSCodeMockPaths.getWorkspaceStorageDir(process.cwd()) + + ensureDirectoryExists(globalStoragePath) + ensureDirectoryExists(workspaceStoragePath) + + this.globalMemento = new FileMemento(path.join(globalStoragePath, "configuration.json")) + this.workspaceMemento = new FileMemento(path.join(workspaceStoragePath, "configuration.json")) + } + } + + get(section: string, defaultValue?: T): T | undefined { + const fullSection = this.section ? `${this.section}.${section}` : section + + // Check runtime configuration first (highest priority - set by CLI at runtime) + const runtimeValue = runtimeConfig.get(fullSection) + if (runtimeValue !== undefined) { + return runtimeValue as T + } + + // Check workspace configuration (persisted to disk) + const workspaceValue = this.workspaceMemento.get(fullSection) + if (workspaceValue !== undefined && workspaceValue !== null) { + return workspaceValue as T + } + + // Check global configuration (persisted to disk) + const globalValue = this.globalMemento.get(fullSection) + if (globalValue !== undefined && globalValue !== null) { + return globalValue as T + } + + // Return default value + return defaultValue + } + + has(section: string): boolean { + const fullSection = this.section ? `${this.section}.${section}` : section + return this.workspaceMemento.get(fullSection) !== undefined || this.globalMemento.get(fullSection) !== undefined + } + + inspect(section: string): ConfigurationInspect | undefined { + const fullSection = this.section ? `${this.section}.${section}` : section + const workspaceValue = this.workspaceMemento.get(fullSection) + const globalValue = this.globalMemento.get(fullSection) + + if (workspaceValue !== undefined || globalValue !== undefined) { + return { + key: fullSection, + defaultValue: undefined, + globalValue: globalValue as T | undefined, + workspaceValue: workspaceValue as T | undefined, + workspaceFolderValue: undefined, + } + } + + return undefined + } + + async update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Promise { + const fullSection = this.section ? `${this.section}.${section}` : section + + try { + // Determine which memento to use based on configuration target + const memento = + configurationTarget === ConfigurationTarget.Workspace ? this.workspaceMemento : this.globalMemento + + const scope = configurationTarget === ConfigurationTarget.Workspace ? "workspace" : "global" + + // Update the memento (this automatically persists to disk) + await memento.update(fullSection, value) + + logs.debug( + `Configuration updated: ${fullSection} = ${JSON.stringify(value)} (${scope})`, + "VSCode.MockWorkspaceConfiguration", + ) + } catch (error) { + logs.error(`Failed to update configuration: ${fullSection}`, "VSCode.MockWorkspaceConfiguration", { + error, + }) + throw error + } + } + + // Additional method to reload configuration from disk + public reload(): void { + // FileMemento automatically loads from disk, so we don't need to do anything special + logs.debug("Configuration reload requested", "VSCode.MockWorkspaceConfiguration") + } + + // Method to get all configuration data (useful for debugging and generic config loading) + public getAllConfig(): Record { + const globalKeys = this.globalMemento.keys() + const workspaceKeys = this.workspaceMemento.keys() + const allConfig: Record = {} + + // Add global settings first + for (const key of globalKeys) { + const value = this.globalMemento.get(key) + if (value !== undefined && value !== null) { + allConfig[key] = value + } + } + + // Add workspace settings (these override global) + for (const key of workspaceKeys) { + const value = this.workspaceMemento.get(key) + if (value !== undefined && value !== null) { + allConfig[key] = value + } + } + + return allConfig + } +} diff --git a/packages/vscode-shim/src/api/create-vscode-api-mock.ts b/packages/vscode-shim/src/api/create-vscode-api-mock.ts new file mode 100644 index 0000000000..1eb22d3675 --- /dev/null +++ b/packages/vscode-shim/src/api/create-vscode-api-mock.ts @@ -0,0 +1,315 @@ +/** + * Main factory function for creating VSCode API mock + */ + +import { machineIdSync } from "../utils/machine-id.js" +import { logs } from "../utils/logger.js" + +// Import classes +import { Uri } from "../classes/Uri.js" +import { Position } from "../classes/Position.js" +import { Range } from "../classes/Range.js" +import { Selection } from "../classes/Selection.js" +import { EventEmitter } from "../classes/EventEmitter.js" +import { TextEdit, WorkspaceEdit } from "../classes/TextEdit.js" +import { + Location, + Diagnostic, + DiagnosticRelatedInformation, + ThemeColor, + ThemeIcon, + CodeActionKind, + CodeLens, + LanguageModelTextPart, + LanguageModelToolCallPart, + LanguageModelToolResultPart, + FileSystemError, +} from "../classes/Additional.js" +import { CancellationTokenSource } from "../classes/CancellationToken.js" +import { StatusBarItem } from "../classes/StatusBarItem.js" +import { ExtensionContextImpl } from "../context/ExtensionContext.js" + +// Import APIs +import { WorkspaceAPI } from "./WorkspaceAPI.js" +import { WindowAPI } from "./WindowAPI.js" +import { CommandsAPI } from "./CommandsAPI.js" + +// Import types and enums +import { + ConfigurationTarget, + ViewColumn, + TextEditorRevealType, + StatusBarAlignment, + DiagnosticSeverity, + DiagnosticTag, + EndOfLine, + UIKind, + ExtensionMode, + FileType, + DecorationRangeBehavior, + OverviewRulerLane, +} from "../types.js" + +// Import interfaces +import type { CancellationToken } from "../interfaces/document.js" +import type { Disposable, DiagnosticCollection, IdentityInfo } from "../interfaces/workspace.js" +import type { RelativePattern } from "../interfaces/document.js" +import type { UriHandler } from "../interfaces/webview.js" + +// Package version constant +const Package = { version: "1.0.0" } + +/** + * Options for creating the VSCode API mock + */ +export interface VSCodeAPIMockOptions { + /** + * Custom app root path (for locating ripgrep and other VSCode resources). + * Defaults to the directory containing this module. + */ + appRoot?: string +} + +/** + * Create a complete VSCode API mock for CLI mode + */ +export function createVSCodeAPIMock( + extensionRootPath: string, + workspacePath: string, + identity?: IdentityInfo, + options?: VSCodeAPIMockOptions, +) { + const context = new ExtensionContextImpl({ + extensionPath: extensionRootPath, + workspacePath: workspacePath, + }) + const workspace = new WorkspaceAPI(workspacePath, context) + const window = new WindowAPI() + const commands = new CommandsAPI() + + // Link window and workspace for cross-API calls + window.setWorkspace(workspace) + + // Environment mock with identity values + const env = { + appName: `wrapper|cli|cli|${Package.version}`, + appRoot: options?.appRoot || import.meta.dirname, + language: "en", + machineId: identity?.machineId || machineIdSync(), + sessionId: identity?.sessionId || "cli-session-id", + remoteName: undefined, + shell: process.env.SHELL || "/bin/bash", + uriScheme: "vscode", + uiKind: 1, // Desktop + openExternal: async (uri: Uri): Promise => { + logs.info(`Would open external URL: ${uri.toString()}`, "VSCode.Env") + return true + }, + clipboard: { + readText: async (): Promise => { + logs.debug("Clipboard read requested", "VSCode.Clipboard") + return "" + }, + writeText: async (text: string): Promise => { + logs.debug( + `Clipboard write: ${text.substring(0, 100)}${text.length > 100 ? "..." : ""}`, + "VSCode.Clipboard", + ) + }, + }, + } + + return { + version: "1.84.0", + Uri, + EventEmitter, + ConfigurationTarget, + ViewColumn, + TextEditorRevealType, + StatusBarAlignment, + DiagnosticSeverity, + DiagnosticTag, + Position, + Range, + Selection, + Location, + Diagnostic, + DiagnosticRelatedInformation, + TextEdit, + WorkspaceEdit, + EndOfLine, + UIKind, + ExtensionMode, + CodeActionKind, + ThemeColor, + ThemeIcon, + DecorationRangeBehavior, + OverviewRulerLane, + StatusBarItem, + CancellationToken: class CancellationTokenClass implements CancellationToken { + isCancellationRequested = false + onCancellationRequested = (_listener: (e: unknown) => void) => ({ dispose: () => {} }) + }, + CancellationTokenSource, + CodeLens, + LanguageModelTextPart, + LanguageModelToolCallPart, + LanguageModelToolResultPart, + ExtensionContext: ExtensionContextImpl, + FileType, + FileSystemError, + Disposable: class DisposableClass implements Disposable { + dispose(): void { + // No-op for CLI + } + + static from(...disposables: Disposable[]): Disposable { + return { + dispose: () => { + disposables.forEach((d) => d.dispose()) + }, + } + } + }, + TabInputText: class TabInputText { + constructor(public uri: Uri) {} + }, + TabInputTextDiff: class TabInputTextDiff { + constructor( + public original: Uri, + public modified: Uri, + ) {} + }, + workspace, + window, + commands, + env, + context, + // Add more APIs as needed + languages: { + registerCodeActionsProvider: () => ({ dispose: () => {} }), + registerCodeLensProvider: () => ({ dispose: () => {} }), + registerCompletionItemProvider: () => ({ dispose: () => {} }), + registerHoverProvider: () => ({ dispose: () => {} }), + registerDefinitionProvider: () => ({ dispose: () => {} }), + registerReferenceProvider: () => ({ dispose: () => {} }), + registerDocumentSymbolProvider: () => ({ dispose: () => {} }), + registerWorkspaceSymbolProvider: () => ({ dispose: () => {} }), + registerRenameProvider: () => ({ dispose: () => {} }), + registerDocumentFormattingEditProvider: () => ({ dispose: () => {} }), + registerDocumentRangeFormattingEditProvider: () => ({ dispose: () => {} }), + registerSignatureHelpProvider: () => ({ dispose: () => {} }), + getDiagnostics: (uri?: Uri): [Uri, Diagnostic[]][] | Diagnostic[] => { + // In CLI mode, we don't have real diagnostics + // Return empty array or empty diagnostics for the specific URI + if (uri) { + return [] + } + return [] + }, + createDiagnosticCollection: (name?: string): DiagnosticCollection => { + const diagnostics = new Map() + const collection: DiagnosticCollection = { + name: name || "default", + set: ( + uriOrEntries: Uri | [Uri, Diagnostic[] | undefined][], + diagnosticsOrUndefined?: Diagnostic[] | undefined, + ) => { + if (Array.isArray(uriOrEntries)) { + // Handle array of entries + for (const [uri, diags] of uriOrEntries) { + if (diags === undefined) { + diagnostics.delete(uri.toString()) + } else { + diagnostics.set(uri.toString(), diags) + } + } + } else { + // Handle single URI + if (diagnosticsOrUndefined === undefined) { + diagnostics.delete(uriOrEntries.toString()) + } else { + diagnostics.set(uriOrEntries.toString(), diagnosticsOrUndefined) + } + } + }, + delete: (uri: Uri) => { + diagnostics.delete(uri.toString()) + }, + clear: () => { + diagnostics.clear() + }, + forEach: ( + callback: (uri: Uri, diagnostics: Diagnostic[], collection: DiagnosticCollection) => void, + thisArg?: unknown, + ) => { + diagnostics.forEach((diags, uriString) => { + callback.call(thisArg, Uri.parse(uriString), diags, collection) + }) + }, + get: (uri: Uri) => { + return diagnostics.get(uri.toString()) + }, + has: (uri: Uri) => { + return diagnostics.has(uri.toString()) + }, + dispose: () => { + diagnostics.clear() + }, + } + return collection + }, + }, + debug: { + onDidStartDebugSession: () => ({ dispose: () => {} }), + onDidTerminateDebugSession: () => ({ dispose: () => {} }), + }, + tasks: { + onDidStartTask: () => ({ dispose: () => {} }), + onDidEndTask: () => ({ dispose: () => {} }), + }, + extensions: { + all: [], + getExtension: (extensionId: string) => { + // Mock the extension object with extensionUri for theme loading + if (extensionId === "RooVeterinaryInc.roo-cline") { + return { + id: extensionId, + extensionUri: context.extensionUri, + extensionPath: context.extensionPath, + isActive: true, + packageJSON: {}, + exports: undefined, + activate: () => Promise.resolve(), + } + } + return undefined + }, + onDidChange: () => ({ dispose: () => {} }), + }, + // Add file system watcher + FileSystemWatcher: class { + onDidChange = () => ({ dispose: () => {} }) + onDidCreate = () => ({ dispose: () => {} }) + onDidDelete = () => ({ dispose: () => {} }) + dispose = () => {} + }, + // Add relative pattern + RelativePattern: class implements RelativePattern { + constructor( + public base: string, + public pattern: string, + ) {} + }, + // Add progress location + ProgressLocation: { + SourceControl: 1, + Window: 10, + Notification: 15, + }, + // Add URI handler + UriHandler: class implements UriHandler { + handleUri = (_uri: Uri) => {} + }, + } +} diff --git a/packages/vscode-shim/src/classes/Additional.ts b/packages/vscode-shim/src/classes/Additional.ts new file mode 100644 index 0000000000..d300eb1e8c --- /dev/null +++ b/packages/vscode-shim/src/classes/Additional.ts @@ -0,0 +1,181 @@ +/** + * Additional VSCode API classes for extension support + * + * This file contains supplementary classes and types that extensions may need. + */ + +import { Range } from "./Range.js" +import type { IUri, IRange, IPosition, DiagnosticSeverity, DiagnosticTag } from "../types.js" + +/** + * Represents a location in source code (URI + Range or Position) + */ +export class Location { + constructor( + public uri: IUri, + public range: IRange | IPosition, + ) {} +} + +/** + * Related diagnostic information + */ +export class DiagnosticRelatedInformation { + constructor( + public location: Location, + public message: string, + ) {} +} + +/** + * Represents a diagnostic (error, warning, etc.) + */ +export class Diagnostic { + range: Range + message: string + severity: DiagnosticSeverity + source?: string + code?: string | number | { value: string | number; target: IUri } + relatedInformation?: DiagnosticRelatedInformation[] + tags?: DiagnosticTag[] + + constructor(range: IRange, message: string, severity?: DiagnosticSeverity) { + this.range = range as Range + this.message = message + this.severity = severity !== undefined ? severity : 0 // Error + } +} + +/** + * Theme color reference + */ +export class ThemeColor { + constructor(public id: string) {} +} + +/** + * Theme icon reference + */ +export class ThemeIcon { + constructor( + public id: string, + public color?: ThemeColor, + ) {} +} + +/** + * Code action kind for categorizing code actions + */ +export class CodeActionKind { + static readonly Empty = new CodeActionKind("") + static readonly QuickFix = new CodeActionKind("quickfix") + static readonly Refactor = new CodeActionKind("refactor") + static readonly RefactorExtract = new CodeActionKind("refactor.extract") + static readonly RefactorInline = new CodeActionKind("refactor.inline") + static readonly RefactorRewrite = new CodeActionKind("refactor.rewrite") + static readonly Source = new CodeActionKind("source") + static readonly SourceOrganizeImports = new CodeActionKind("source.organizeImports") + + constructor(public value: string) {} + + append(parts: string): CodeActionKind { + return new CodeActionKind(this.value ? `${this.value}.${parts}` : parts) + } + + intersects(other: CodeActionKind): boolean { + return this.contains(other) || other.contains(this) + } + + contains(other: CodeActionKind): boolean { + return this.value === other.value || other.value.startsWith(this.value + ".") + } +} + +/** + * Code lens for displaying inline information + */ +export class CodeLens { + public range: Range + public command?: { command: string; title: string; arguments?: unknown[] } | undefined + public isResolved: boolean = false + + constructor(range: IRange, command?: { command: string; title: string; arguments?: unknown[] } | undefined) { + this.range = range as Range + this.command = command + } +} + +/** + * Language Model API parts + */ +export class LanguageModelTextPart { + constructor(public value: string) {} +} + +export class LanguageModelToolCallPart { + constructor( + public callId: string, + public name: string, + public input: unknown, + ) {} +} + +export class LanguageModelToolResultPart { + constructor( + public callId: string, + public content: unknown[], + ) {} +} + +/** + * File system error with specific error codes + */ +export class FileSystemError extends Error { + public code: string + + constructor(message: string, code: string = "Unknown") { + super(message) + this.name = "FileSystemError" + this.code = code + } + + static FileNotFound(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" ? messageOrUri : `File not found: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "FileNotFound") + } + + static FileExists(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" ? messageOrUri : `File exists: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "FileExists") + } + + static FileNotADirectory(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" + ? messageOrUri + : `File is not a directory: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "FileNotADirectory") + } + + static FileIsADirectory(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" + ? messageOrUri + : `File is a directory: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "FileIsADirectory") + } + + static NoPermissions(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" ? messageOrUri : `No permissions: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "NoPermissions") + } + + static Unavailable(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" ? messageOrUri : `Unavailable: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "Unavailable") + } +} diff --git a/packages/vscode-shim/src/classes/CancellationToken.ts b/packages/vscode-shim/src/classes/CancellationToken.ts new file mode 100644 index 0000000000..1efcd91e4e --- /dev/null +++ b/packages/vscode-shim/src/classes/CancellationToken.ts @@ -0,0 +1,48 @@ +/** + * CancellationToken and CancellationTokenSource for VSCode API + */ + +import { EventEmitter } from "./EventEmitter.js" +import type { Disposable } from "../interfaces/workspace.js" + +/** + * Cancellation token interface + */ +export interface CancellationToken { + isCancellationRequested: boolean + onCancellationRequested: (listener: (e: unknown) => void) => Disposable +} + +/** + * CancellationTokenSource creates and controls a CancellationToken + */ +export class CancellationTokenSource { + private _token: CancellationToken + private _isCancelled = false + private _onCancellationRequestedEmitter = new EventEmitter() + + constructor() { + this._token = { + isCancellationRequested: false, + onCancellationRequested: this._onCancellationRequestedEmitter.event, + } + } + + get token(): CancellationToken { + return this._token + } + + cancel(): void { + if (!this._isCancelled) { + this._isCancelled = true + // Type assertion needed to modify readonly property + ;(this._token as { isCancellationRequested: boolean }).isCancellationRequested = true + this._onCancellationRequestedEmitter.fire(undefined) + } + } + + dispose(): void { + this.cancel() + this._onCancellationRequestedEmitter.dispose() + } +} diff --git a/packages/vscode-shim/src/classes/EventEmitter.ts b/packages/vscode-shim/src/classes/EventEmitter.ts new file mode 100644 index 0000000000..c561114c00 --- /dev/null +++ b/packages/vscode-shim/src/classes/EventEmitter.ts @@ -0,0 +1,88 @@ +import type { Disposable, Event } from "../types.js" + +/** + * VSCode-compatible EventEmitter implementation + * + * Provides a type-safe event emitter that matches VSCode's EventEmitter API. + * Listeners can subscribe to events and will be notified when events are fired. + * + * @example + * ```typescript + * const emitter = new EventEmitter() + * + * // Subscribe to events + * const disposable = emitter.event((value) => { + * console.log('Event fired:', value) + * }) + * + * // Fire an event + * emitter.fire('Hello, world!') + * + * // Clean up + * disposable.dispose() + * emitter.dispose() + * ``` + */ +export class EventEmitter { + readonly #listeners = new Set<(e: T) => void>() + + /** + * The event that listeners can subscribe to + * + * @param listener - The callback function to invoke when the event fires + * @param thisArgs - Optional 'this' context for the listener + * @param disposables - Optional array to add the disposable to + * @returns A disposable to unsubscribe from the event + */ + event: Event = (listener: (e: T) => void, thisArgs?: unknown, disposables?: Disposable[]): Disposable => { + const fn = thisArgs ? listener.bind(thisArgs) : listener + this.#listeners.add(fn) + + const disposable: Disposable = { + dispose: () => { + this.#listeners.delete(fn) + }, + } + + if (disposables) { + disposables.push(disposable) + } + + return disposable + } + + /** + * Fire the event, notifying all subscribers + * + * Failure of one or more listeners will not fail this function call. + * Failed listeners will be caught and ignored to prevent one listener + * from breaking others. + * + * @param data - The event data to pass to listeners + */ + fire(data: T): void { + for (const listener of this.#listeners) { + try { + listener(data) + } catch (error) { + // Silently ignore listener errors to prevent one failing listener + // from affecting others. Consumers can add error handling in their listeners. + console.error("EventEmitter listener error:", error) + } + } + } + + /** + * Dispose this event emitter and remove all listeners + */ + dispose(): void { + this.#listeners.clear() + } + + /** + * Get the current number of listeners (useful for debugging) + */ + get listenerCount(): number { + return this.#listeners.size + } +} diff --git a/packages/vscode-shim/src/classes/OutputChannel.ts b/packages/vscode-shim/src/classes/OutputChannel.ts new file mode 100644 index 0000000000..f5b6c1e778 --- /dev/null +++ b/packages/vscode-shim/src/classes/OutputChannel.ts @@ -0,0 +1,46 @@ +/** + * OutputChannel class for VSCode API + */ + +import { logs } from "../utils/logger.js" +import type { Disposable } from "../interfaces/workspace.js" + +/** + * Output channel mock for CLI mode + * Logs output to the configured logger instead of VSCode's output panel + */ +export class OutputChannel implements Disposable { + private _name: string + + constructor(name: string) { + this._name = name + } + + get name(): string { + return this._name + } + + append(value: string): void { + logs.info(`[${this._name}] ${value}`, "VSCode.OutputChannel") + } + + appendLine(value: string): void { + logs.info(`[${this._name}] ${value}`, "VSCode.OutputChannel") + } + + clear(): void { + // No-op for CLI + } + + show(): void { + // No-op for CLI + } + + hide(): void { + // No-op for CLI + } + + dispose(): void { + // No-op for CLI + } +} diff --git a/packages/vscode-shim/src/classes/Position.ts b/packages/vscode-shim/src/classes/Position.ts new file mode 100644 index 0000000000..729381d126 --- /dev/null +++ b/packages/vscode-shim/src/classes/Position.ts @@ -0,0 +1,148 @@ +import type { IPosition } from "../types.js" + +/** + * Represents a position in a text document + * + * A position is defined by a zero-based line number and a zero-based character offset. + * This class is immutable - all methods that modify the position return a new instance. + * + * @example + * ```typescript + * const pos = new Position(5, 10) // Line 5, character 10 + * const next = pos.translate(1, 0) // Line 6, character 10 + * ``` + */ +export class Position implements IPosition { + /** + * The zero-based line number + */ + public readonly line: number + + /** + * The zero-based character offset + */ + public readonly character: number + + /** + * Create a new Position + * + * @param line - The zero-based line number + * @param character - The zero-based character offset + */ + constructor(line: number, character: number) { + if (line < 0) { + throw new Error("Line number must be non-negative") + } + if (character < 0) { + throw new Error("Character offset must be non-negative") + } + this.line = line + this.character = character + } + + /** + * Check if this position is equal to another position + */ + isEqual(other: IPosition): boolean { + return this.line === other.line && this.character === other.character + } + + /** + * Check if this position is before another position + */ + isBefore(other: IPosition): boolean { + if (this.line < other.line) { + return true + } + if (this.line === other.line) { + return this.character < other.character + } + return false + } + + /** + * Check if this position is before or equal to another position + */ + isBeforeOrEqual(other: IPosition): boolean { + return this.isBefore(other) || this.isEqual(other) + } + + /** + * Check if this position is after another position + */ + isAfter(other: IPosition): boolean { + return !this.isBeforeOrEqual(other) + } + + /** + * Check if this position is after or equal to another position + */ + isAfterOrEqual(other: IPosition): boolean { + return !this.isBefore(other) + } + + /** + * Compare this position to another + * + * @returns -1 if this position is before, 0 if equal, 1 if after + */ + compareTo(other: IPosition): number { + if (this.line < other.line) { + return -1 + } + if (this.line > other.line) { + return 1 + } + if (this.character < other.character) { + return -1 + } + if (this.character > other.character) { + return 1 + } + return 0 + } + + /** + * Create a new position relative to this position + * + * @param lineDelta - The line delta (default: 0) + * @param characterDelta - The character delta (default: 0) + * @returns A new Position + */ + translate(lineDelta?: number, characterDelta?: number): Position + translate(change: { lineDelta?: number; characterDelta?: number }): Position + translate( + lineDeltaOrChange?: number | { lineDelta?: number; characterDelta?: number }, + characterDelta?: number, + ): Position { + if (typeof lineDeltaOrChange === "object") { + return new Position( + this.line + (lineDeltaOrChange.lineDelta || 0), + this.character + (lineDeltaOrChange.characterDelta || 0), + ) + } + return new Position(this.line + (lineDeltaOrChange || 0), this.character + (characterDelta || 0)) + } + + /** + * Create a new position with changed line or character + * + * @param line - The new line number (or undefined to keep current) + * @param character - The new character offset (or undefined to keep current) + * @returns A new Position + */ + with(line?: number, character?: number): Position + with(change: { line?: number; character?: number }): Position + with(lineOrChange?: number | { line?: number; character?: number }, character?: number): Position { + if (typeof lineOrChange === "object") { + return new Position( + lineOrChange.line !== undefined ? lineOrChange.line : this.line, + lineOrChange.character !== undefined ? lineOrChange.character : this.character, + ) + } + return new Position( + lineOrChange !== undefined ? lineOrChange : this.line, + character !== undefined ? character : this.character, + ) + } +} diff --git a/packages/vscode-shim/src/classes/Range.ts b/packages/vscode-shim/src/classes/Range.ts new file mode 100644 index 0000000000..35a3afcb3b --- /dev/null +++ b/packages/vscode-shim/src/classes/Range.ts @@ -0,0 +1,137 @@ +import { Position } from "./Position.js" +import type { IRange, IPosition } from "../types.js" + +/** + * Represents a range in a text document + * + * A range is defined by two positions: a start and an end position. + * This class is immutable - all methods that modify the range return a new instance. + * + * @example + * ```typescript + * // Create a range from line 0 to line 5 + * const range = new Range( + * new Position(0, 0), + * new Position(5, 10) + * ) + * + * // Or use the overload with line/character numbers + * const range2 = new Range(0, 0, 5, 10) + * ``` + */ +export class Range implements IRange { + public readonly start: Position + public readonly end: Position + + /** + * Create a new Range + * + * @param start - The start position + * @param end - The end position + */ + constructor(start: IPosition, end: IPosition) + /** + * Create a new Range from line and character numbers + * + * @param startLine - The start line number + * @param startCharacter - The start character offset + * @param endLine - The end line number + * @param endCharacter - The end character offset + */ + constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number) + constructor( + startOrStartLine: IPosition | number, + endOrStartCharacter: IPosition | number, + endLine?: number, + endCharacter?: number, + ) { + if (typeof startOrStartLine === "number") { + this.start = new Position(startOrStartLine, endOrStartCharacter as number) + this.end = new Position(endLine!, endCharacter!) + } else { + this.start = startOrStartLine as Position + this.end = endOrStartCharacter as Position + } + } + + /** + * Check if this range is empty (start equals end) + */ + get isEmpty(): boolean { + return this.start.isEqual(this.end) + } + + /** + * Check if this range is on a single line + */ + get isSingleLine(): boolean { + return this.start.line === this.end.line + } + + /** + * Check if this range contains a position or range + * + * @param positionOrRange - The position or range to check + * @returns true if the position/range is within this range + */ + contains(positionOrRange: IPosition | IRange): boolean { + if ("start" in positionOrRange && "end" in positionOrRange) { + // It's a range + return this.contains(positionOrRange.start) && this.contains(positionOrRange.end) + } + // It's a position + return positionOrRange.isAfterOrEqual(this.start) && positionOrRange.isBeforeOrEqual(this.end) + } + + /** + * Check if this range is equal to another range + */ + isEqual(other: IRange): boolean { + return this.start.isEqual(other.start) && this.end.isEqual(other.end) + } + + /** + * Get the intersection of this range with another range + * + * @param other - The other range + * @returns The intersection range, or undefined if they don't intersect + */ + intersection(other: IRange): Range | undefined { + const start = this.start.isAfter(other.start) ? this.start : other.start + const end = this.end.isBefore(other.end) ? this.end : other.end + if (start.isAfter(end)) { + return undefined + } + return new Range(start, end) + } + + /** + * Get the union of this range with another range + * + * @param other - The other range + * @returns A new range that spans both ranges + */ + union(other: IRange): Range { + const start = this.start.isBefore(other.start) ? this.start : other.start + const end = this.end.isAfter(other.end) ? this.end : other.end + return new Range(start, end) + } + + /** + * Create a new range with modified start or end positions + * + * @param start - The new start position (or undefined to keep current) + * @param end - The new end position (or undefined to keep current) + * @returns A new Range + */ + with(start?: IPosition, end?: IPosition): Range + with(change: { start?: IPosition; end?: IPosition }): Range + with(startOrChange?: IPosition | { start?: IPosition; end?: IPosition }, end?: IPosition): Range { + // Check if it's a change object (has start or end property, but not line/character like a Position) + if (startOrChange && typeof startOrChange === "object" && !("line" in startOrChange)) { + const change = startOrChange as { start?: IPosition; end?: IPosition } + return new Range(change.start || this.start, change.end || this.end) + } + return new Range((startOrChange as IPosition) || this.start, end || this.end) + } +} diff --git a/packages/vscode-shim/src/classes/Selection.ts b/packages/vscode-shim/src/classes/Selection.ts new file mode 100644 index 0000000000..10fcc9969e --- /dev/null +++ b/packages/vscode-shim/src/classes/Selection.ts @@ -0,0 +1,79 @@ +import { Range } from "./Range.js" +import { Position } from "./Position.js" +import type { ISelection, IPosition } from "../types.js" + +/** + * Represents a text selection in an editor + * + * A selection extends Range with anchor and active positions. + * The anchor is where the selection starts, and the active is where it ends. + * The selection can be reversed if the active position is before the anchor. + * + * @example + * ```typescript + * // Create a selection from position 0,0 to 5,10 + * const selection = new Selection( + * new Position(0, 0), + * new Position(5, 10) + * ) + * + * console.log(selection.isReversed) // false + * ``` + */ +export class Selection extends Range implements ISelection { + /** + * The anchor position (where the selection started) + */ + public readonly anchor: Position + + /** + * The active position (where the selection currently ends) + */ + public readonly active: Position + + /** + * Create a new Selection + * + * @param anchor - The anchor position + * @param active - The active position + */ + constructor(anchor: IPosition, active: IPosition) + /** + * Create a new Selection from line and character numbers + * + * @param anchorLine - The anchor line number + * @param anchorCharacter - The anchor character offset + * @param activeLine - The active line number + * @param activeCharacter - The active character offset + */ + constructor(anchorLine: number, anchorCharacter: number, activeLine: number, activeCharacter: number) + constructor( + anchorOrAnchorLine: IPosition | number, + activeOrAnchorCharacter: IPosition | number, + activeLine?: number, + activeCharacter?: number, + ) { + let anchor: Position + let active: Position + + if (typeof anchorOrAnchorLine === "number") { + anchor = new Position(anchorOrAnchorLine, activeOrAnchorCharacter as number) + active = new Position(activeLine!, activeCharacter!) + } else { + anchor = anchorOrAnchorLine as Position + active = activeOrAnchorCharacter as Position + } + + super(anchor, active) + this.anchor = anchor + this.active = active + } + + /** + * Check if the selection is reversed + * A reversed selection has the active position before the anchor position + */ + get isReversed(): boolean { + return this.anchor.isAfter(this.active) + } +} diff --git a/packages/vscode-shim/src/classes/StatusBarItem.ts b/packages/vscode-shim/src/classes/StatusBarItem.ts new file mode 100644 index 0000000000..bde8f860d6 --- /dev/null +++ b/packages/vscode-shim/src/classes/StatusBarItem.ts @@ -0,0 +1,79 @@ +/** + * StatusBarItem class for VSCode API + */ + +import { StatusBarAlignment } from "../types.js" +import type { Disposable } from "../interfaces/workspace.js" + +/** + * Status bar item mock for CLI mode + */ +export class StatusBarItem implements Disposable { + private _text: string = "" + private _tooltip: string | undefined + private _command: string | undefined + private _color: string | undefined + private _backgroundColor: string | undefined + private _isVisible: boolean = false + + constructor( + public readonly alignment: StatusBarAlignment, + public readonly priority?: number, + ) {} + + get text(): string { + return this._text + } + + set text(value: string) { + this._text = value + } + + get tooltip(): string | undefined { + return this._tooltip + } + + set tooltip(value: string | undefined) { + this._tooltip = value + } + + get command(): string | undefined { + return this._command + } + + set command(value: string | undefined) { + this._command = value + } + + get color(): string | undefined { + return this._color + } + + set color(value: string | undefined) { + this._color = value + } + + get backgroundColor(): string | undefined { + return this._backgroundColor + } + + set backgroundColor(value: string | undefined) { + this._backgroundColor = value + } + + get isVisible(): boolean { + return this._isVisible + } + + show(): void { + this._isVisible = true + } + + hide(): void { + this._isVisible = false + } + + dispose(): void { + this._isVisible = false + } +} diff --git a/packages/vscode-shim/src/classes/TextEdit.ts b/packages/vscode-shim/src/classes/TextEdit.ts new file mode 100644 index 0000000000..503f4d224f --- /dev/null +++ b/packages/vscode-shim/src/classes/TextEdit.ts @@ -0,0 +1,209 @@ +import { Position } from "./Position.js" +import { Range } from "./Range.js" +import type { IRange, IPosition } from "../types.js" + +/** + * Represents a text edit operation + * + * A text edit replaces text in a specific range with new text. + * This is used to modify documents programmatically. + * + * @example + * ```typescript + * // Replace text in a range + * const edit = TextEdit.replace( + * new Range(0, 0, 0, 5), + * 'Hello' + * ) + * + * // Insert text at a position + * const insert = TextEdit.insert( + * new Position(0, 0), + * 'New text' + * ) + * + * // Delete text in a range + * const deletion = TextEdit.delete( + * new Range(0, 0, 0, 10) + * ) + * ``` + */ +export class TextEdit { + /** + * The range to replace + */ + public readonly range: Range + + /** + * The new text (empty string for deletion) + */ + public readonly newText: string + + /** + * Create a new TextEdit + * + * @param range - The range to replace + * @param newText - The new text + */ + constructor(range: IRange, newText: string) { + this.range = range as Range + this.newText = newText + } + + /** + * Create a replace edit + * + * @param range - The range to replace + * @param newText - The new text + * @returns A new TextEdit + */ + static replace(range: IRange, newText: string): TextEdit { + return new TextEdit(range, newText) + } + + /** + * Create an insert edit + * + * @param position - The position to insert at + * @param newText - The text to insert + * @returns A new TextEdit + */ + static insert(position: IPosition, newText: string): TextEdit { + return new TextEdit(new Range(position, position), newText) + } + + /** + * Create a delete edit + * + * @param range - The range to delete + * @returns A new TextEdit + */ + static delete(range: IRange): TextEdit { + return new TextEdit(range, "") + } + + /** + * Create an edit to set the end of line sequence + * + * @returns A new TextEdit (simplified implementation) + */ + static setEndOfLine(): TextEdit { + return new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "") + } +} + +/** + * Represents a collection of text edits for a document + * + * A WorkspaceEdit can contain edits for multiple documents. + * + * @example + * ```typescript + * const edit = new WorkspaceEdit() + * + * // Add edits for a file + * edit.set(uri, [ + * TextEdit.replace(range1, 'new text'), + * TextEdit.insert(pos, 'inserted') + * ]) + * + * // Apply the edit + * await vscode.workspace.applyEdit(edit) + * ``` + */ +export class WorkspaceEdit { + private _edits: Map = new Map() + + /** + * Set edits for a specific URI + * + * @param uri - The document URI + * @param edits - Array of text edits + */ + set(uri: { toString(): string }, edits: TextEdit[]): void { + this._edits.set(uri.toString(), edits) + } + + /** + * Get edits for a specific URI + * + * @param uri - The document URI + * @returns Array of text edits, or empty array if none + */ + get(uri: { toString(): string }): TextEdit[] { + return this._edits.get(uri.toString()) || [] + } + + /** + * Check if edits exist for a URI + * + * @param uri - The document URI + * @returns true if edits exist + */ + has(uri: { toString(): string }): boolean { + return this._edits.has(uri.toString()) + } + + /** + * Add a delete edit for a range + * + * @param uri - The document URI + * @param range - The range to delete + */ + delete(uri: { toString(): string }, range: IRange): void { + const key = uri.toString() + if (!this._edits.has(key)) { + this._edits.set(key, []) + } + this._edits.get(key)!.push(TextEdit.delete(range)) + } + + /** + * Add an insert edit + * + * @param uri - The document URI + * @param position - The position to insert at + * @param newText - The text to insert + */ + insert(uri: { toString(): string }, position: IPosition, newText: string): void { + const key = uri.toString() + if (!this._edits.has(key)) { + this._edits.set(key, []) + } + this._edits.get(key)!.push(TextEdit.insert(position, newText)) + } + + /** + * Add a replace edit + * + * @param uri - The document URI + * @param range - The range to replace + * @param newText - The new text + */ + replace(uri: { toString(): string }, range: IRange, newText: string): void { + const key = uri.toString() + if (!this._edits.has(key)) { + this._edits.set(key, []) + } + this._edits.get(key)!.push(TextEdit.replace(range, newText)) + } + + /** + * Get the number of documents with edits + */ + get size(): number { + return this._edits.size + } + + /** + * Get all URI and edits pairs + * + * @returns Array of [URI, TextEdit[]] pairs + */ + entries(): [{ toString(): string; fsPath: string }, TextEdit[]][] { + return Array.from(this._edits.entries()).map(([uriString, edits]) => { + // Parse the URI string back to a URI-like object + return [{ toString: () => uriString, fsPath: uriString.replace(/^file:\/\//, "") }, edits] + }) + } +} diff --git a/packages/vscode-shim/src/classes/TextEditorDecorationType.ts b/packages/vscode-shim/src/classes/TextEditorDecorationType.ts new file mode 100644 index 0000000000..0e59c67acf --- /dev/null +++ b/packages/vscode-shim/src/classes/TextEditorDecorationType.ts @@ -0,0 +1,20 @@ +/** + * TextEditorDecorationType class for VSCode API + */ + +import type { Disposable } from "../interfaces/workspace.js" + +/** + * Text editor decoration type mock for CLI mode + */ +export class TextEditorDecorationType implements Disposable { + public key: string + + constructor(key: string) { + this.key = key + } + + dispose(): void { + // No-op for CLI + } +} diff --git a/packages/vscode-shim/src/classes/Uri.ts b/packages/vscode-shim/src/classes/Uri.ts new file mode 100644 index 0000000000..7ee7c5dc68 --- /dev/null +++ b/packages/vscode-shim/src/classes/Uri.ts @@ -0,0 +1,124 @@ +import * as path from "path" + +/** + * Uniform Resource Identifier (URI) implementation + * + * Represents a URI following the RFC 3986 standard. + * This class is compatible with VSCode's Uri class and provides + * file system path handling for cross-platform compatibility. + * + * @example + * ```typescript + * // Create a file URI + * const fileUri = Uri.file('/path/to/file.txt') + * console.log(fileUri.fsPath) // '/path/to/file.txt' + * + * // Parse a URI string + * const uri = Uri.parse('https://example.com/path?query=1#fragment') + * console.log(uri.scheme) // 'https' + * console.log(uri.path) // '/path' + * ``` + */ +export class Uri { + public readonly scheme: string + public readonly authority: string + public readonly path: string + public readonly query: string + public readonly fragment: string + + constructor(scheme: string, authority: string, path: string, query: string, fragment: string) { + this.scheme = scheme + this.authority = authority + this.path = path + this.query = query + this.fragment = fragment + } + + /** + * Create a URI from a file system path + * + * @param path - The file system path + * @returns A new Uri instance with 'file' scheme + */ + static file(fsPath: string): Uri { + return new Uri("file", "", fsPath, "", "") + } + + /** + * Parse a URI string + * + * @param value - The URI string to parse + * @returns A new Uri instance + */ + static parse(value: string): Uri { + try { + const url = new URL(value) + return new Uri( + url.protocol.slice(0, -1), + url.hostname, + url.pathname, + url.search.slice(1), + url.hash.slice(1), + ) + } catch { + // If URL parsing fails, treat as file path + return Uri.file(value) + } + } + + /** + * Join a URI with path segments + * + * @param base - The base URI + * @param pathSegments - Path segments to join + * @returns A new Uri with the joined path + */ + static joinPath(base: Uri, ...pathSegments: string[]): Uri { + const joinedPath = path.join(base.path, ...pathSegments) + return new Uri(base.scheme, base.authority, joinedPath, base.query, base.fragment) + } + + /** + * Create a new URI with modifications + * + * @param change - The changes to apply + * @returns A new Uri instance with the changes applied + */ + with(change: { scheme?: string; authority?: string; path?: string; query?: string; fragment?: string }): Uri { + return new Uri( + change.scheme !== undefined ? change.scheme : this.scheme, + change.authority !== undefined ? change.authority : this.authority, + change.path !== undefined ? change.path : this.path, + change.query !== undefined ? change.query : this.query, + change.fragment !== undefined ? change.fragment : this.fragment, + ) + } + + /** + * Get the file system path representation + * Compatible with both Unix and Windows paths + */ + get fsPath(): string { + return this.path + } + + /** + * Convert the URI to a string representation + */ + toString(): string { + return `${this.scheme}://${this.authority}${this.path}${this.query ? "?" + this.query : ""}${this.fragment ? "#" + this.fragment : ""}` + } + + /** + * Convert to JSON representation + */ + toJSON(): object { + return { + scheme: this.scheme, + authority: this.authority, + path: this.path, + query: this.query, + fragment: this.fragment, + } + } +} diff --git a/packages/vscode-shim/src/context/ExtensionContext.ts b/packages/vscode-shim/src/context/ExtensionContext.ts new file mode 100644 index 0000000000..324478bf34 --- /dev/null +++ b/packages/vscode-shim/src/context/ExtensionContext.ts @@ -0,0 +1,158 @@ +import * as path from "path" +import * as fs from "fs" +import { Uri } from "../classes/Uri.js" +import { FileMemento } from "../storage/Memento.js" +import { FileSecretStorage } from "../storage/SecretStorage.js" +import { hashWorkspacePath, ensureDirectoryExists } from "../utils/paths.js" +import type { + ExtensionContext, + Extension, + Disposable, + Memento, + SecretStorage, + ExtensionMode, + ExtensionKind, +} from "../types.js" + +/** + * Options for creating an ExtensionContext + */ +export interface ExtensionContextOptions { + /** + * Path to the extension's root directory + */ + extensionPath: string + + /** + * Path to the workspace directory + */ + workspacePath: string + + /** + * Optional custom storage directory (defaults to ~/.vscode-mock) + */ + storageDir?: string + + /** + * Extension mode (Production, Development, or Test) + */ + extensionMode?: ExtensionMode +} + +/** + * Implementation of VSCode's ExtensionContext + * + * Provides the context object passed to extension activation functions. + * This includes state storage, secrets, and extension metadata. + * + * @example + * ```typescript + * const context = new ExtensionContextImpl({ + * extensionPath: '/path/to/extension', + * workspacePath: '/path/to/workspace' + * }) + * + * // Use in extension activation + * const api = await extension.activate(context) + * ``` + */ +export class ExtensionContextImpl implements ExtensionContext { + public subscriptions: Disposable[] = [] + public workspaceState: Memento + public globalState: Memento & { setKeysForSync(keys: readonly string[]): void } + public secrets: SecretStorage + public extensionUri: Uri + public extensionPath: string + public environmentVariableCollection: Record = {} + public storageUri: Uri | undefined + public storagePath: string | undefined + public globalStorageUri: Uri + public globalStoragePath: string + public logUri: Uri + public logPath: string + public extensionMode: ExtensionMode + public extension: Extension | undefined + + constructor(options: ExtensionContextOptions) { + this.extensionPath = options.extensionPath + this.extensionUri = Uri.file(options.extensionPath) + this.extensionMode = options.extensionMode || 1 // Default to Production + + // Setup storage paths + const baseStorageDir = + options.storageDir || path.join(process.env.HOME || process.env.USERPROFILE || ".", ".vscode-mock") + const workspaceHash = hashWorkspacePath(options.workspacePath) + + this.globalStoragePath = path.join(baseStorageDir, "global-storage") + this.globalStorageUri = Uri.file(this.globalStoragePath) + + const workspaceStoragePath = path.join(baseStorageDir, "workspace-storage", workspaceHash) + this.storagePath = workspaceStoragePath + this.storageUri = Uri.file(workspaceStoragePath) + + this.logPath = path.join(baseStorageDir, "logs") + this.logUri = Uri.file(this.logPath) + + // Ensure directories exist + ensureDirectoryExists(this.globalStoragePath) + ensureDirectoryExists(workspaceStoragePath) + ensureDirectoryExists(this.logPath) + + // Initialize state storage + this.workspaceState = new FileMemento(path.join(workspaceStoragePath, "workspace-state.json")) + + const globalMemento = new FileMemento(path.join(this.globalStoragePath, "global-state.json")) + this.globalState = Object.assign(globalMemento, { + setKeysForSync: (_keys: readonly string[]) => { + // No-op for mock implementation + }, + }) + + this.secrets = new FileSecretStorage(this.globalStoragePath) + + // Load extension metadata (packageJSON) + this.extension = this.loadExtensionMetadata() + } + + /** + * Load extension metadata from package.json + */ + private loadExtensionMetadata(): Extension | undefined { + try { + // Try to load package.json from extension path + const packageJsonPath = path.join(this.extensionPath, "package.json") + if (fs.existsSync(packageJsonPath)) { + const packageJSON = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) + const extensionId = `${packageJSON.publisher || "unknown"}.${packageJSON.name || "unknown"}` + + return { + id: extensionId, + extensionUri: this.extensionUri, + extensionPath: this.extensionPath, + isActive: true, + packageJSON, + exports: undefined, + extensionKind: 1 as ExtensionKind, // UI + activate: () => Promise.resolve(undefined), + } + } + } catch { + // Ignore errors loading package.json + } + return undefined + } + + /** + * Dispose all subscriptions + */ + dispose(): void { + for (const subscription of this.subscriptions) { + try { + subscription.dispose() + } catch (error) { + console.error("Error disposing subscription:", error) + } + } + this.subscriptions = [] + } +} diff --git a/packages/vscode-shim/src/index.ts b/packages/vscode-shim/src/index.ts new file mode 100644 index 0000000000..02c1b2f2b8 --- /dev/null +++ b/packages/vscode-shim/src/index.ts @@ -0,0 +1,112 @@ +/** + * @roo-code/vscode-shim + * + * A production-ready VSCode API mock for running VSCode extensions in Node.js CLI applications. + * This package provides a complete implementation of the VSCode Extension API, allowing you to + * run VSCode extensions without VSCode installed. + * + * @packageDocumentation + */ + +// Export the complete VSCode API implementation +export { + // Main factory function + createVSCodeAPIMock, + + // Classes + Uri, + Position, + Range, + Selection, + EventEmitter, + Location, + Diagnostic, + DiagnosticRelatedInformation, + TextEdit, + WorkspaceEdit, + ThemeColor, + ThemeIcon, + CodeActionKind, + CancellationTokenSource, + CodeLens, + LanguageModelTextPart, + LanguageModelToolCallPart, + LanguageModelToolResultPart, + FileSystemError, + OutputChannel, + StatusBarItem, + TextEditorDecorationType, + ExtensionContext, + + // API classes + WorkspaceAPI, + WindowAPI, + CommandsAPI, + TabGroupsAPI, + FileSystemAPI, + MockWorkspaceConfiguration, + + // Runtime configuration utilities + setRuntimeConfig, + setRuntimeConfigValues, + clearRuntimeConfig, + getRuntimeConfig, + + // Enums + ConfigurationTarget, + ViewColumn, + TextEditorRevealType, + StatusBarAlignment, + DiagnosticSeverity, + DiagnosticTag, + EndOfLine, + UIKind, + ExtensionMode, + ExtensionKind, + FileType, + DecorationRangeBehavior, + OverviewRulerLane, + + // Types + type IdentityInfo, + type Thenable, + type Disposable, + type TextDocument, + type TextLine, + type WorkspaceFolder, + type WorkspaceConfiguration, + type Memento, + type SecretStorage, + type FileStat, + type Terminal, + type CancellationToken, +} from "./vscode.js" + +// Export utilities +export { logs, setLogger, type Logger } from "./utils/logger.js" +export { VSCodeMockPaths } from "./utils/paths.js" +export { machineIdSync } from "./utils/machine-id.js" + +// Re-export as createVSCodeAPI for simpler API +export { createVSCodeAPIMock as createVSCodeAPI } from "./vscode.js" + +/** + * Quick start function to create a complete VSCode API mock + * + * @example + * ```typescript + * import { createVSCodeAPI } from '@roo-code/vscode-shim' + * + * const vscode = createVSCodeAPI({ + * extensionPath: '/path/to/extension', + * workspacePath: '/path/to/workspace' + * }) + * + * // Set global vscode for extension to use + * global.vscode = vscode + * + * // Load and activate extension + * const extension = require('/path/to/extension.js') + * const api = await extension.activate(vscode.context) + * ``` + */ diff --git a/packages/vscode-shim/src/interfaces/document.ts b/packages/vscode-shim/src/interfaces/document.ts new file mode 100644 index 0000000000..0b1ec0eb7d --- /dev/null +++ b/packages/vscode-shim/src/interfaces/document.ts @@ -0,0 +1,114 @@ +/** + * Document-related interfaces for VSCode API + */ + +import type { Range } from "../classes/Range.js" +import type { Position } from "../classes/Position.js" +import type { Uri } from "../classes/Uri.js" +import type { Thenable, Disposable } from "../types.js" + +/** + * Represents a text document in VSCode + */ +export interface TextDocument { + uri: Uri + fileName: string + languageId: string + version: number + isDirty: boolean + isClosed: boolean + lineCount: number + getText(range?: Range): string + lineAt(line: number): TextLine + offsetAt(position: Position): number + positionAt(offset: number): Position + save(): Thenable + validateRange(range: Range): Range + validatePosition(position: Position): Position +} + +/** + * Represents a line of text in a document + */ +export interface TextLine { + text: string + range: Range + rangeIncludingLineBreak: Range + firstNonWhitespaceCharacterIndex: number + isEmptyOrWhitespace: boolean +} + +/** + * Event fired when workspace folders change + */ +export interface WorkspaceFoldersChangeEvent { + added: WorkspaceFolder[] + removed: WorkspaceFolder[] +} + +/** + * Represents a workspace folder + */ +export interface WorkspaceFolder { + uri: Uri + name: string + index: number +} + +/** + * Event fired when a text document changes + */ +export interface TextDocumentChangeEvent { + document: TextDocument + contentChanges: readonly TextDocumentContentChangeEvent[] +} + +/** + * Represents a change in a text document + */ +export interface TextDocumentContentChangeEvent { + range: Range + rangeOffset: number + rangeLength: number + text: string +} + +/** + * Event fired when configuration changes + */ +export interface ConfigurationChangeEvent { + affectsConfiguration(section: string, scope?: Uri): boolean +} + +/** + * Provider for text document content + */ +export interface TextDocumentContentProvider { + provideTextDocumentContent(uri: Uri, token: CancellationToken): Thenable + onDidChange?: (listener: (e: Uri) => void) => Disposable +} + +/** + * Cancellation token interface (must be local to avoid conflict with ES2023 built-in) + */ +export interface CancellationToken { + isCancellationRequested: boolean + onCancellationRequested: (listener: (e: unknown) => void) => Disposable +} + +/** + * File system watcher interface + */ +export interface FileSystemWatcher extends Disposable { + onDidChange: (listener: (e: Uri) => void) => Disposable + onDidCreate: (listener: (e: Uri) => void) => Disposable + onDidDelete: (listener: (e: Uri) => void) => Disposable +} + +/** + * Relative pattern for file matching + */ +export interface RelativePattern { + base: string + pattern: string +} diff --git a/packages/vscode-shim/src/interfaces/editor.ts b/packages/vscode-shim/src/interfaces/editor.ts new file mode 100644 index 0000000000..c1a288abe2 --- /dev/null +++ b/packages/vscode-shim/src/interfaces/editor.ts @@ -0,0 +1,107 @@ +/** + * Editor-related interfaces for VSCode API + */ + +import type { Range } from "../classes/Range.js" +import type { Position } from "../classes/Position.js" +import type { Selection } from "../classes/Selection.js" +import type { Uri } from "../classes/Uri.js" +import type { ThemeColor } from "../classes/Additional.js" +import type { + Thenable, + ViewColumn, + TextEditorRevealType, + EndOfLine, + DecorationRangeBehavior, + OverviewRulerLane, + TextEditorOptions, +} from "../types.js" +import type { TextDocument } from "./document.js" +import type { Disposable } from "../types.js" + +/** + * Represents a text editor in VSCode + */ +export interface TextEditor { + document: TextDocument + selection: Selection + selections: Selection[] + visibleRanges: Range[] + options: TextEditorOptions + viewColumn?: ViewColumn + edit(callback: (editBuilder: TextEditorEdit) => void): Thenable + insertSnippet( + snippet: unknown, + location?: Position | Range | readonly Position[] | readonly Range[], + ): Thenable + setDecorations(decorationType: TextEditorDecorationType, rangesOrOptions: readonly Range[]): void + revealRange(range: Range, revealType?: TextEditorRevealType): void + show(column?: ViewColumn): void + hide(): void +} + +/** + * Builder for text editor edits + */ +export interface TextEditorEdit { + replace(location: Position | Range | Selection, value: string): void + insert(location: Position, value: string): void + delete(location: Range | Selection): void + setEndOfLine(endOfLine: EndOfLine): void +} + +/** + * Event fired when text editor selection changes + */ +export interface TextEditorSelectionChangeEvent { + textEditor: TextEditor + selections: readonly Selection[] + kind?: number +} + +/** + * Options for showing a text document + */ +export interface TextDocumentShowOptions { + viewColumn?: ViewColumn + preserveFocus?: boolean + preview?: boolean + selection?: Range +} + +/** + * Options for rendering decorations + */ +export interface DecorationRenderOptions { + backgroundColor?: string | ThemeColor + border?: string + borderColor?: string | ThemeColor + borderRadius?: string + borderSpacing?: string + borderStyle?: string + borderWidth?: string + color?: string | ThemeColor + cursor?: string + fontStyle?: string + fontWeight?: string + gutterIconPath?: string | Uri + gutterIconSize?: string + isWholeLine?: boolean + letterSpacing?: string + opacity?: string + outline?: string + outlineColor?: string | ThemeColor + outlineStyle?: string + outlineWidth?: string + overviewRulerColor?: string | ThemeColor + overviewRulerLane?: OverviewRulerLane + rangeBehavior?: DecorationRangeBehavior + textDecoration?: string +} + +/** + * Text editor decoration type interface + */ +export interface TextEditorDecorationType extends Disposable { + key: string +} diff --git a/packages/vscode-shim/src/interfaces/terminal.ts b/packages/vscode-shim/src/interfaces/terminal.ts new file mode 100644 index 0000000000..343d0177d3 --- /dev/null +++ b/packages/vscode-shim/src/interfaces/terminal.ts @@ -0,0 +1,76 @@ +/** + * Terminal-related interfaces for VSCode API + */ + +import type { Uri } from "../classes/Uri.js" +import type { ThemeIcon } from "../classes/Additional.js" +import type { Thenable } from "../types.js" + +/** + * Represents a terminal in VSCode + */ +export interface Terminal { + name: string + processId: Thenable + creationOptions: Readonly + exitStatus: TerminalExitStatus | undefined + state: TerminalState + sendText(text: string, addNewLine?: boolean): void + show(preserveFocus?: boolean): void + hide(): void + dispose(): void +} + +/** + * Options for creating a terminal + */ +export interface TerminalOptions { + name?: string + shellPath?: string + shellArgs?: string[] | string + cwd?: string | Uri + env?: { [key: string]: string | null | undefined } + iconPath?: Uri | ThemeIcon + hideFromUser?: boolean + message?: string + strictEnv?: boolean +} + +/** + * Exit status of a terminal + */ +export interface TerminalExitStatus { + code: number | undefined + reason: number +} + +/** + * State of a terminal + */ +export interface TerminalState { + isInteractedWith: boolean +} + +/** + * Event fired when terminal dimensions change + */ +export interface TerminalDimensionsChangeEvent { + terminal: Terminal + dimensions: TerminalDimensions +} + +/** + * Terminal dimensions + */ +export interface TerminalDimensions { + columns: number + rows: number +} + +/** + * Event fired when data is written to terminal + */ +export interface TerminalDataWriteEvent { + terminal: Terminal + data: string +} diff --git a/packages/vscode-shim/src/interfaces/webview.ts b/packages/vscode-shim/src/interfaces/webview.ts new file mode 100644 index 0000000000..c69d3a10e5 --- /dev/null +++ b/packages/vscode-shim/src/interfaces/webview.ts @@ -0,0 +1,92 @@ +/** + * Webview-related interfaces for VSCode API + */ + +import type { Uri } from "../classes/Uri.js" +import type { Thenable, Disposable } from "../types.js" +import type { CancellationToken } from "./document.js" + +/** + * Webview view provider interface + */ +export interface WebviewViewProvider { + resolveWebviewView( + webviewView: WebviewView, + context: WebviewViewResolveContext, + token: CancellationToken, + ): Thenable | void +} + +/** + * Webview view interface + */ +export interface WebviewView { + webview: Webview + viewType: string + title?: string + description?: string + badge?: ViewBadge + show(preserveFocus?: boolean): void + onDidChangeVisibility: (listener: () => void) => Disposable + onDidDispose: (listener: () => void) => Disposable + visible: boolean +} + +/** + * Webview interface + */ +export interface Webview { + html: string + options: WebviewOptions + cspSource: string + postMessage(message: unknown): Thenable + onDidReceiveMessage: (listener: (message: unknown) => void) => Disposable + asWebviewUri(localResource: Uri): Uri +} + +/** + * Webview options interface + */ +export interface WebviewOptions { + enableScripts?: boolean + enableForms?: boolean + localResourceRoots?: readonly Uri[] + portMapping?: readonly WebviewPortMapping[] +} + +/** + * Webview port mapping interface + */ +export interface WebviewPortMapping { + webviewPort: number + extensionHostPort: number +} + +/** + * View badge interface + */ +export interface ViewBadge { + tooltip: string + value: number +} + +/** + * Webview view resolve context + */ +export interface WebviewViewResolveContext { + state?: unknown +} + +/** + * Webview view provider options + */ +export interface WebviewViewProviderOptions { + retainContextWhenHidden?: boolean +} + +/** + * URI handler interface + */ +export interface UriHandler { + handleUri(uri: Uri): void +} diff --git a/packages/vscode-shim/src/interfaces/workspace.ts b/packages/vscode-shim/src/interfaces/workspace.ts new file mode 100644 index 0000000000..5271420ae0 --- /dev/null +++ b/packages/vscode-shim/src/interfaces/workspace.ts @@ -0,0 +1,91 @@ +/** + * Workspace-related interfaces for VSCode API + */ + +import type { Uri } from "../classes/Uri.js" +import type { Thenable, ConfigurationTarget, ConfigurationInspect } from "../types.js" + +/** + * Workspace configuration interface + */ +export interface WorkspaceConfiguration { + get(section: string): T | undefined + get(section: string, defaultValue: T): T + has(section: string): boolean + inspect(section: string): ConfigurationInspect | undefined + update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Thenable +} + +/** + * Quick pick options interface + */ +export interface QuickPickOptions { + placeHolder?: string + canPickMany?: boolean + ignoreFocusOut?: boolean + matchOnDescription?: boolean + matchOnDetail?: boolean +} + +/** + * Input box options interface + */ +export interface InputBoxOptions { + value?: string + valueSelection?: [number, number] + prompt?: string + placeHolder?: string + password?: boolean + ignoreFocusOut?: boolean + validateInput?(value: string): string | undefined | null | Thenable +} + +/** + * Open dialog options interface + */ +export interface OpenDialogOptions { + defaultUri?: Uri + openLabel?: string + canSelectFiles?: boolean + canSelectFolders?: boolean + canSelectMany?: boolean + filters?: { [name: string]: string[] } + title?: string +} + +/** + * Disposable interface for VSCode API (must be local to avoid conflict with ES2023 built-in Disposable) + */ +export interface Disposable { + dispose(): void +} + +/** + * Diagnostic collection interface + */ +export interface DiagnosticCollection extends Disposable { + name: string + set(uri: Uri, diagnostics: import("../classes/Additional.js").Diagnostic[] | undefined): void + set(entries: [Uri, import("../classes/Additional.js").Diagnostic[] | undefined][]): void + delete(uri: Uri): void + clear(): void + forEach( + callback: ( + uri: Uri, + diagnostics: import("../classes/Additional.js").Diagnostic[], + collection: DiagnosticCollection, + ) => void, + thisArg?: unknown, + ): void + get(uri: Uri): import("../classes/Additional.js").Diagnostic[] | undefined + has(uri: Uri): boolean +} + +/** + * Identity information for VSCode environment + */ +export interface IdentityInfo { + machineId: string + sessionId: string + cliUserId?: string +} diff --git a/packages/vscode-shim/src/storage/Memento.ts b/packages/vscode-shim/src/storage/Memento.ts new file mode 100644 index 0000000000..5c26d12c5c --- /dev/null +++ b/packages/vscode-shim/src/storage/Memento.ts @@ -0,0 +1,115 @@ +import * as fs from "fs" +import * as path from "path" +import { ensureDirectoryExists } from "../utils/paths.js" +import type { Memento } from "../types.js" + +/** + * File-based implementation of VSCode's Memento interface + * + * Provides persistent key-value storage backed by a JSON file. + * This implementation automatically loads from and saves to disk. + * + * @example + * ```typescript + * const memento = new FileMemento('/path/to/state.json') + * + * // Store a value + * await memento.update('lastOpenFile', '/path/to/file.txt') + * + * // Retrieve a value + * const file = memento.get('lastOpenFile') + * + * // With default value + * const count = memento.get('count', 0) + * ``` + */ +export class FileMemento implements Memento { + private data: Record = {} + private filePath: string + + /** + * Create a new FileMemento + * + * @param filePath - Path to the JSON file for persistence + */ + constructor(filePath: string) { + this.filePath = filePath + this.loadFromFile() + } + + /** + * Load data from the JSON file + */ + private loadFromFile(): void { + try { + if (fs.existsSync(this.filePath)) { + const content = fs.readFileSync(this.filePath, "utf-8") + this.data = JSON.parse(content) + } + } catch (error) { + console.warn(`Failed to load state from ${this.filePath}:`, error) + this.data = {} + } + } + + /** + * Save data to the JSON file + */ + private saveToFile(): void { + try { + // Ensure directory exists + const dir = path.dirname(this.filePath) + ensureDirectoryExists(dir) + fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2)) + } catch (error) { + console.warn(`Failed to save state to ${this.filePath}:`, error) + } + } + + /** + * Get a value from storage + * + * @param key - The key to retrieve + * @param defaultValue - Optional default value if key doesn't exist + * @returns The stored value or default value + */ + get(key: string): T | undefined + get(key: string, defaultValue: T): T + get(key: string, defaultValue?: T): T | undefined { + const value = this.data[key] + return value !== undefined && value !== null ? (value as T) : defaultValue + } + + /** + * Update a value in storage + * + * @param key - The key to update + * @param value - The value to store (undefined to delete) + * @returns A promise that resolves when the update is complete + */ + async update(key: string, value: unknown): Promise { + if (value === undefined) { + delete this.data[key] + } else { + this.data[key] = value + } + this.saveToFile() + } + + /** + * Get all keys in storage + * + * @returns An array of all keys + */ + keys(): readonly string[] { + return Object.keys(this.data) + } + + /** + * Clear all data from storage + */ + clear(): void { + this.data = {} + this.saveToFile() + } +} diff --git a/packages/vscode-shim/src/storage/SecretStorage.ts b/packages/vscode-shim/src/storage/SecretStorage.ts new file mode 100644 index 0000000000..372a33f403 --- /dev/null +++ b/packages/vscode-shim/src/storage/SecretStorage.ts @@ -0,0 +1,138 @@ +import * as fs from "fs" +import * as path from "path" +import { EventEmitter } from "../classes/EventEmitter.js" +import { ensureDirectoryExists } from "../utils/paths.js" +import type { SecretStorage, SecretStorageChangeEvent } from "../types.js" + +/** + * File-based implementation of VSCode's SecretStorage interface + * + * Stores secrets in a JSON file on disk. While not encrypted like VSCode's + * native keychain integration, this provides a simple, cross-platform solution + * suitable for CLI applications. + * + * **Security Notes:** + * - Secrets are stored as plain JSON (not encrypted) + * - File permissions should be set restrictive (0600) + * - For production, consider using environment variables instead + * - Suitable for development and non-critical secrets + * + * @example + * ```typescript + * const storage = new FileSecretStorage('/path/to/secrets.json') + * + * // Store a secret + * await storage.store('apiKey', 'sk-...') + * + * // Retrieve a secret + * const key = await storage.get('apiKey') + * + * // Listen for changes + * storage.onDidChange((e) => { + * console.log(`Secret ${e.key} changed`) + * }) + * ``` + */ +export class FileSecretStorage implements SecretStorage { + private secrets: Record = {} + private _onDidChange = new EventEmitter() + private filePath: string + + /** + * Create a new FileSecretStorage + * + * @param storagePath - Directory path where secrets.json will be stored + */ + constructor(storagePath: string) { + this.filePath = path.join(storagePath, "secrets.json") + this.loadFromFile() + } + + /** + * Load secrets from the JSON file + */ + private loadFromFile(): void { + try { + if (fs.existsSync(this.filePath)) { + const content = fs.readFileSync(this.filePath, "utf-8") + this.secrets = JSON.parse(content) + } + } catch (error) { + console.warn(`Failed to load secrets from ${this.filePath}:`, error) + this.secrets = {} + } + } + + /** + * Save secrets to the JSON file with restrictive permissions + */ + private saveToFile(): void { + try { + // Ensure directory exists + const dir = path.dirname(this.filePath) + ensureDirectoryExists(dir) + + // Write the file + fs.writeFileSync(this.filePath, JSON.stringify(this.secrets, null, 2)) + + // Set restrictive permissions (owner read/write only) on Unix-like systems + if (process.platform !== "win32") { + try { + fs.chmodSync(this.filePath, 0o600) + } catch { + // Ignore chmod errors (might not be supported on some filesystems) + } + } + } catch (error) { + console.warn(`Failed to save secrets to ${this.filePath}:`, error) + } + } + + /** + * Retrieve a secret by key + * + * @param key - The secret key + * @returns The secret value or undefined if not found + */ + async get(key: string): Promise { + return this.secrets[key] + } + + /** + * Store a secret + * + * @param key - The secret key + * @param value - The secret value + */ + async store(key: string, value: string): Promise { + this.secrets[key] = value + this.saveToFile() + this._onDidChange.fire({ key }) + } + + /** + * Delete a secret + * + * @param key - The secret key to delete + */ + async delete(key: string): Promise { + delete this.secrets[key] + this.saveToFile() + this._onDidChange.fire({ key }) + } + + /** + * Event fired when a secret changes + */ + get onDidChange() { + return this._onDidChange.event + } + + /** + * Clear all secrets (useful for testing) + */ + clearAll(): void { + this.secrets = {} + this.saveToFile() + } +} diff --git a/packages/vscode-shim/src/types.ts b/packages/vscode-shim/src/types.ts new file mode 100644 index 0000000000..d21a99c43d --- /dev/null +++ b/packages/vscode-shim/src/types.ts @@ -0,0 +1,344 @@ +/** + * Core VSCode API type definitions + * + * This file contains TypeScript type definitions that match the VSCode Extension API. + * These types allow VSCode extensions to run in Node.js without VSCode installed. + */ + +/** + * Represents a thenable (Promise-like) value + */ +export type Thenable = Promise + +/** + * Represents a disposable resource that can be cleaned up + */ +export interface Disposable { + dispose(): void +} + +/** + * Represents a Uniform Resource Identifier (URI) + */ +export interface IUri { + scheme: string + authority: string + path: string + query: string + fragment: string + fsPath: string + toString(): string +} + +/** + * Represents a position in a text document (line and character) + */ +export interface IPosition { + line: number + character: number + isEqual(other: IPosition): boolean + isBefore(other: IPosition): boolean + isBeforeOrEqual(other: IPosition): boolean + isAfter(other: IPosition): boolean + isAfterOrEqual(other: IPosition): boolean + compareTo(other: IPosition): number +} + +/** + * Represents a range in a text document (start and end positions) + */ +export interface IRange { + start: IPosition + end: IPosition + isEmpty: boolean + isSingleLine: boolean + contains(positionOrRange: IPosition | IRange): boolean + isEqual(other: IRange): boolean + intersection(other: IRange): IRange | undefined + union(other: IRange): IRange +} + +/** + * Represents a selection in a text editor (extends Range with anchor and active positions) + */ +export interface ISelection extends IRange { + anchor: IPosition + active: IPosition + isReversed: boolean +} + +/** + * Represents a line of text in a document + */ +export interface TextLine { + text: string + range: IRange + rangeIncludingLineBreak: IRange + firstNonWhitespaceCharacterIndex: number + isEmptyOrWhitespace: boolean +} + +/** + * Represents a text document + */ +export interface TextDocument { + uri: IUri + fileName: string + languageId: string + version: number + isDirty: boolean + isClosed: boolean + lineCount: number + getText(range?: IRange): string + lineAt(line: number): TextLine + offsetAt(position: IPosition): number + positionAt(offset: number): IPosition + save(): Thenable + validateRange(range: IRange): IRange + validatePosition(position: IPosition): IPosition +} + +/** + * Configuration target for settings + */ +export enum ConfigurationTarget { + Global = 1, + Workspace = 2, + WorkspaceFolder = 3, +} + +/** + * Workspace folder representation + */ +export interface WorkspaceFolder { + uri: IUri + name: string + index: number +} + +/** + * Workspace configuration interface + */ +export interface WorkspaceConfiguration { + get(section: string): T | undefined + get(section: string, defaultValue: T): T + has(section: string): boolean + inspect(section: string): ConfigurationInspect | undefined + update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Thenable +} + +/** + * Configuration inspection result + */ +export interface ConfigurationInspect { + key: string + defaultValue?: T + globalValue?: T + workspaceValue?: T + workspaceFolderValue?: T +} + +/** + * Memento (state storage) interface + */ +export interface Memento { + get(key: string): T | undefined + get(key: string, defaultValue: T): T + update(key: string, value: unknown): Thenable + keys(): readonly string[] +} + +/** + * Secret storage interface for secure credential storage + */ +export interface SecretStorage { + get(key: string): Thenable + store(key: string, value: string): Thenable + delete(key: string): Thenable + onDidChange: Event +} + +/** + * Secret storage change event + */ +export interface SecretStorageChangeEvent { + key: string +} + +/** + * Represents an extension + */ +export interface Extension { + id: string + extensionUri: IUri + extensionPath: string + isActive: boolean + packageJSON: Record + exports: T + extensionKind: ExtensionKind + activate(): Thenable +} + +/** + * Extension kind enum + */ +export enum ExtensionKind { + UI = 1, + Workspace = 2, +} + +/** + * Extension context provided to extension activation + */ +export interface ExtensionContext { + subscriptions: Disposable[] + workspaceState: Memento + globalState: Memento & { setKeysForSync(keys: readonly string[]): void } + secrets: SecretStorage + extensionUri: IUri + extensionPath: string + environmentVariableCollection: Record + storageUri: IUri | undefined + storagePath: string | undefined + globalStorageUri: IUri + globalStoragePath: string + logUri: IUri + logPath: string + extensionMode: ExtensionMode + extension: Extension | undefined +} + +/** + * Extension mode enum + */ +export enum ExtensionMode { + Production = 1, + Development = 2, + Test = 3, +} + +/** + * Event emitter event type + */ +export type Event = (listener: (e: T) => void, thisArgs?: unknown, disposables?: Disposable[]) => Disposable + +/** + * Cancellation token for async operations + */ +export interface CancellationToken { + isCancellationRequested: boolean + onCancellationRequested: Event +} + +/** + * File system file type enum + */ +export enum FileType { + Unknown = 0, + File = 1, + Directory = 2, + SymbolicLink = 64, +} + +/** + * File system stat information + */ +export interface FileStat { + type: FileType + ctime: number + mtime: number + size: number +} + +/** + * Text editor options + */ +export interface TextEditorOptions { + tabSize?: number + insertSpaces?: boolean + cursorStyle?: number + lineNumbers?: number +} + +/** + * View column enum for editor placement + */ +export enum ViewColumn { + Active = -1, + Beside = -2, + One = 1, + Two = 2, + Three = 3, +} + +/** + * UI Kind enum + */ +export enum UIKind { + Desktop = 1, + Web = 2, +} + +/** + * End of line sequence enum + */ +export enum EndOfLine { + LF = 1, + CRLF = 2, +} + +/** + * Status bar alignment + */ +export enum StatusBarAlignment { + Left = 1, + Right = 2, +} + +/** + * Diagnostic severity levels + */ +export enum DiagnosticSeverity { + Error = 0, + Warning = 1, + Information = 2, + Hint = 3, +} + +/** + * Diagnostic tags + */ +export enum DiagnosticTag { + Unnecessary = 1, + Deprecated = 2, +} + +/** + * Overview ruler lane + */ +export enum OverviewRulerLane { + Left = 1, + Center = 2, + Right = 4, + Full = 7, +} + +/** + * Decoration range behavior + */ +export enum DecorationRangeBehavior { + OpenOpen = 0, + ClosedClosed = 1, + OpenClosed = 2, + ClosedOpen = 3, +} + +/** + * Text editor reveal type + */ +export enum TextEditorRevealType { + Default = 0, + InCenter = 1, + InCenterIfOutsideViewport = 2, + AtTop = 3, +} diff --git a/packages/vscode-shim/src/utils/logger.ts b/packages/vscode-shim/src/utils/logger.ts new file mode 100644 index 0000000000..5d8d387e55 --- /dev/null +++ b/packages/vscode-shim/src/utils/logger.ts @@ -0,0 +1,52 @@ +/** + * Simple logger stub for VSCode mock + * Users can provide their own logger by calling setLogger() + */ + +export interface Logger { + info(message: string, context?: string, meta?: unknown): void + warn(message: string, context?: string, meta?: unknown): void + error(message: string, context?: string, meta?: unknown): void + debug(message: string, context?: string, meta?: unknown): void +} + +class ConsoleLogger implements Logger { + info(message: string, context?: string, _meta?: unknown): void { + console.log(`[${context || "INFO"}] ${message}`) + } + + warn(message: string, context?: string, _meta?: unknown): void { + console.warn(`[${context || "WARN"}] ${message}`) + } + + error(message: string, context?: string, _meta?: unknown): void { + console.error(`[${context || "ERROR"}] ${message}`) + } + + debug(message: string, context?: string, _meta?: unknown): void { + if (process.env.DEBUG) { + console.debug(`[${context || "DEBUG"}] ${message}`) + } + } +} + +let logger: Logger = new ConsoleLogger() + +/** + * Set a custom logger + * + * @param customLogger - Your logger implementation + */ +export function setLogger(customLogger: Logger): void { + logger = customLogger +} + +/** + * Get the current logger + */ +export const logs = { + info: (message: string, context?: string, meta?: unknown) => logger.info(message, context, meta), + warn: (message: string, context?: string, meta?: unknown) => logger.warn(message, context, meta), + error: (message: string, context?: string, meta?: unknown) => logger.error(message, context, meta), + debug: (message: string, context?: string, meta?: unknown) => logger.debug(message, context, meta), +} diff --git a/packages/vscode-shim/src/utils/machine-id.ts b/packages/vscode-shim/src/utils/machine-id.ts new file mode 100644 index 0000000000..744d7d138a --- /dev/null +++ b/packages/vscode-shim/src/utils/machine-id.ts @@ -0,0 +1,44 @@ +/** + * Machine ID generation + * Simple implementation to replace node-machine-id dependency + */ + +import * as fs from "fs" +import * as path from "path" +import * as crypto from "crypto" +import * as os from "os" +import { ensureDirectoryExists } from "./paths.js" + +/** + * Get or create a unique machine ID + * Stores in ~/.vscode-mock/.machine-id for persistence + */ +export function machineIdSync(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || "." + const idPath = path.join(homeDir, ".vscode-mock", ".machine-id") + + // Try to read existing ID + try { + if (fs.existsSync(idPath)) { + return fs.readFileSync(idPath, "utf-8").trim() + } + } catch { + // Fall through to generate new ID + } + + // Generate new ID based on hostname and random data + const hostname = os.hostname() + const randomData = crypto.randomBytes(16).toString("hex") + const machineId = crypto.createHash("sha256").update(`${hostname}-${randomData}`).digest("hex") + + // Save for future use + try { + const dir = path.dirname(idPath) + ensureDirectoryExists(dir) + fs.writeFileSync(idPath, machineId) + } catch { + // Ignore save errors + } + + return machineId +} diff --git a/packages/vscode-shim/src/utils/paths.ts b/packages/vscode-shim/src/utils/paths.ts new file mode 100644 index 0000000000..948c25429e --- /dev/null +++ b/packages/vscode-shim/src/utils/paths.ts @@ -0,0 +1,89 @@ +/** + * Path utilities for VSCode mock storage + */ + +import * as fs from "fs" +import * as path from "path" + +const STORAGE_BASE_DIR = ".vscode-mock" + +/** + * Get the base storage directory + */ +function getBaseStorageDir(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || "." + return path.join(homeDir, STORAGE_BASE_DIR) +} + +/** + * Hash a workspace path to create a unique directory name + * + * @param workspacePath - The workspace path to hash + * @returns A hexadecimal hash string + */ +export function hashWorkspacePath(workspacePath: string): string { + let hash = 0 + for (let i = 0; i < workspacePath.length; i++) { + const char = workspacePath.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32-bit integer + } + return Math.abs(hash).toString(16) +} + +/** + * Ensure a directory exists, creating it if necessary + * + * @param dirPath - The directory path to ensure exists + */ +export function ensureDirectoryExists(dirPath: string): void { + try { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }) + } + } catch (error) { + console.warn(`Failed to create directory ${dirPath}:`, error) + } +} + +/** + * Initialize workspace directories + */ +export function initializeWorkspace(workspacePath: string): void { + const dirs = [getGlobalStorageDir(), getWorkspaceStorageDir(workspacePath), getLogsDir()] + + for (const dir of dirs) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + } +} + +/** + * Get global storage directory + */ +export function getGlobalStorageDir(): string { + return path.join(getBaseStorageDir(), "global-storage") +} + +/** + * Get workspace-specific storage directory + */ +export function getWorkspaceStorageDir(workspacePath: string): string { + const hash = hashWorkspacePath(workspacePath) + return path.join(getBaseStorageDir(), "workspace-storage", hash) +} + +/** + * Get logs directory + */ +export function getLogsDir(): string { + return path.join(getBaseStorageDir(), "logs") +} + +export const VSCodeMockPaths = { + initializeWorkspace, + getGlobalStorageDir, + getWorkspaceStorageDir, + getLogsDir, +} diff --git a/packages/vscode-shim/src/vscode.ts b/packages/vscode-shim/src/vscode.ts new file mode 100644 index 0000000000..27dbedc770 --- /dev/null +++ b/packages/vscode-shim/src/vscode.ts @@ -0,0 +1,153 @@ +/** + * VSCode API Mock - Barrel Export File + * + * This file re-exports all components from the modular files for backwards compatibility. + * All imports from this file will continue to work as before. + */ + +// ============================================================================ +// Classes from ./classes/ +// ============================================================================ +export { Position } from "./classes/Position.js" +export { Range } from "./classes/Range.js" +export { Selection } from "./classes/Selection.js" +export { Uri } from "./classes/Uri.js" +export { EventEmitter } from "./classes/EventEmitter.js" +export { TextEdit, WorkspaceEdit } from "./classes/TextEdit.js" +export { + Location, + Diagnostic, + DiagnosticRelatedInformation, + ThemeColor, + ThemeIcon, + CodeActionKind, + CodeLens, + LanguageModelTextPart, + LanguageModelToolCallPart, + LanguageModelToolResultPart, + FileSystemError, +} from "./classes/Additional.js" +export { CancellationTokenSource, type CancellationToken } from "./classes/CancellationToken.js" +export { OutputChannel } from "./classes/OutputChannel.js" +export { StatusBarItem } from "./classes/StatusBarItem.js" +export { TextEditorDecorationType } from "./classes/TextEditorDecorationType.js" + +// ============================================================================ +// Context +// ============================================================================ +export { ExtensionContextImpl as ExtensionContext } from "./context/ExtensionContext.js" + +// ============================================================================ +// API Classes from ./api/ +// ============================================================================ +export { FileSystemAPI } from "./api/FileSystemAPI.js" +export { + MockWorkspaceConfiguration, + setRuntimeConfig, + setRuntimeConfigValues, + clearRuntimeConfig, + getRuntimeConfig, +} from "./api/WorkspaceConfiguration.js" +export { WorkspaceAPI } from "./api/WorkspaceAPI.js" +export { TabGroupsAPI, type Tab, type TabInputText, type TabGroup } from "./api/TabGroupsAPI.js" +export { WindowAPI } from "./api/WindowAPI.js" +export { CommandsAPI } from "./api/CommandsAPI.js" +export { createVSCodeAPIMock } from "./api/create-vscode-api-mock.js" + +// ============================================================================ +// Enums from ./types.ts +// ============================================================================ +export { + ConfigurationTarget, + ViewColumn, + TextEditorRevealType, + StatusBarAlignment, + DiagnosticSeverity, + DiagnosticTag, + EndOfLine, + UIKind, + ExtensionMode, + ExtensionKind, + FileType, + DecorationRangeBehavior, + OverviewRulerLane, +} from "./types.js" + +// ============================================================================ +// Types from ./types.ts +// ============================================================================ +export type { Thenable, Memento, FileStat, TextEditorOptions, ConfigurationInspect } from "./types.js" + +// ============================================================================ +// Interfaces from ./interfaces/ +// ============================================================================ + +// Document interfaces +export type { + TextDocument, + TextLine, + WorkspaceFoldersChangeEvent, + WorkspaceFolder, + TextDocumentChangeEvent, + TextDocumentContentChangeEvent, + ConfigurationChangeEvent, + TextDocumentContentProvider, + FileSystemWatcher, + RelativePattern, +} from "./interfaces/document.js" + +// Editor interfaces +export type { + TextEditor, + TextEditorEdit, + TextEditorSelectionChangeEvent, + TextDocumentShowOptions, + DecorationRenderOptions, +} from "./interfaces/editor.js" + +// Terminal interfaces +export type { + Terminal, + TerminalOptions, + TerminalExitStatus, + TerminalState, + TerminalDimensionsChangeEvent, + TerminalDimensions, + TerminalDataWriteEvent, +} from "./interfaces/terminal.js" + +// Webview interfaces +export type { + WebviewViewProvider, + WebviewView, + Webview, + WebviewOptions, + WebviewPortMapping, + ViewBadge, + WebviewViewResolveContext, + WebviewViewProviderOptions, + UriHandler, +} from "./interfaces/webview.js" + +// Workspace interfaces +export type { + WorkspaceConfiguration, + QuickPickOptions, + InputBoxOptions, + OpenDialogOptions, + Disposable, + DiagnosticCollection, + IdentityInfo, +} from "./interfaces/workspace.js" + +// ============================================================================ +// Secret Storage interface (backwards compatibility) +// ============================================================================ +export interface SecretStorage { + get(key: string): Thenable + store(key: string, value: string): Thenable + delete(key: string): Thenable +} + +// Import Thenable for SecretStorage interface +import type { Thenable } from "./types.js" diff --git a/packages/vscode-shim/tsconfig.json b/packages/vscode-shim/tsconfig.json new file mode 100644 index 0000000000..2a73ee92bb --- /dev/null +++ b/packages/vscode-shim/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@roo-code/config-typescript/base.json", + "compilerOptions": { + "types": ["vitest/globals"], + "outDir": "dist" + }, + "include": ["src", "scripts", "*.config.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/vscode-shim/vitest.config.ts b/packages/vscode-shim/vitest.config.ts new file mode 100644 index 0000000000..b6d6dbb880 --- /dev/null +++ b/packages/vscode-shim/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globals: true, + environment: "node", + watch: false, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5589df1b42..983a6e97b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,43 @@ importers: specifier: ^5.4.5 version: 5.8.3 + apps/cli: + dependencies: + '@roo-code/types': + specifier: workspace:^ + version: link:../../packages/types + '@roo-code/vscode-shim': + specifier: workspace:^ + version: link:../../packages/vscode-shim + '@vscode/ripgrep': + specifier: ^1.15.9 + version: 1.17.0 + commander: + specifier: ^12.1.0 + version: 12.1.0 + devDependencies: + '@roo-code/config-eslint': + specifier: workspace:^ + version: link:../../packages/config-eslint + '@roo-code/config-typescript': + specifier: workspace:^ + version: link:../../packages/config-typescript + '@types/node': + specifier: ^24.1.0 + version: 24.2.1 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + tsup: + specifier: ^8.4.0 + version: 8.5.0(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0) + typescript: + specifier: 5.8.3 + version: 5.8.3 + vitest: + specifier: ^3.2.3 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + apps/vscode-e2e: devDependencies: '@roo-code/config-eslint': @@ -650,6 +687,21 @@ importers: specifier: ^3.2.3 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + packages/vscode-shim: + devDependencies: + '@roo-code/config-eslint': + specifier: workspace:^ + version: link:../config-eslint + '@roo-code/config-typescript': + specifier: workspace:^ + version: link:../config-typescript + '@types/node': + specifier: ^24.1.0 + version: 24.2.1 + vitest: + specifier: ^3.2.3 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + src: dependencies: '@anthropic-ai/bedrock-sdk': @@ -745,6 +797,9 @@ importers: get-folder-size: specifier: ^5.0.0 version: 5.0.0 + global-agent: + specifier: ^3.0.0 + version: 3.0.0 google-auth-library: specifier: ^9.15.1 version: 9.15.1 @@ -871,6 +926,9 @@ importers: turndown: specifier: ^7.2.0 version: 7.2.0 + undici: + specifier: '>=5.29.0' + version: 6.21.3 uuid: specifier: ^11.1.0 version: 11.1.0 @@ -4362,6 +4420,9 @@ packages: '@vscode/codicons@0.0.36': resolution: {integrity: sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==} + '@vscode/ripgrep@1.17.0': + resolution: {integrity: sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g==} + '@vscode/test-cli@0.0.11': resolution: {integrity: sha512-qO332yvzFqGhBMJrp6TdwbIydiHgCtxXc2Nl6M58mbH/Z+0CyLR76Jzv4YWPEthhrARprzCRJUqzFvTHFhTj7Q==} engines: {node: '>=18'} @@ -4721,6 +4782,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bowser@2.11.0: resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} @@ -5499,6 +5564,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -5815,6 +5883,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -6394,6 +6465,10 @@ packages: engines: {node: 20 || >=22} hasBin: true + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} @@ -7519,6 +7594,10 @@ packages: engines: {node: '>= 20'} hasBin: true + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -8865,6 +8944,10 @@ packages: engines: {node: 20 || >=22} hasBin: true + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} @@ -8986,6 +9069,10 @@ packages: resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==} engines: {node: '>=18'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -9685,6 +9772,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -14180,6 +14271,14 @@ snapshots: '@vscode/codicons@0.0.36': {} + '@vscode/ripgrep@1.17.0': + dependencies: + https-proxy-agent: 7.0.6 + proxy-from-env: 1.1.0 + yauzl: 2.10.0 + transitivePeerDependencies: + - supports-color + '@vscode/test-cli@0.0.11': dependencies: '@types/mocha': 10.0.10 @@ -14619,6 +14718,8 @@ snapshots: boolbase@1.0.0: {} + boolean@3.2.0: {} + bowser@2.11.0: {} brace-expansion@2.0.2: @@ -15406,6 +15507,8 @@ snapshots: detect-node-es@1.1.0: {} + detect-node@2.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -15698,6 +15801,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es6-error@4.1.1: {} + esbuild-register@3.6.0(esbuild@0.25.9): dependencies: debug: 4.4.1(supports-color@8.1.1) @@ -16459,6 +16564,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.0 + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.3 + serialize-error: 7.0.1 + globals@11.12.0: {} globals@14.0.0: {} @@ -17229,7 +17343,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.2 + semver: 7.7.3 jsx-ast-utils@3.3.5: dependencies: @@ -17568,7 +17682,7 @@ snapshots: ansi-escapes: 7.0.0 cli-cursor: 5.0.0 slice-ansi: 7.1.0 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 wrap-ansi: 9.0.0 longest-streak@3.1.0: {} @@ -17653,6 +17767,10 @@ snapshots: marked@16.2.0: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + math-intrinsics@1.1.0: {} mdast-util-definitions@4.0.0: @@ -18295,7 +18413,7 @@ snapshots: node-abi@3.75.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 optional: true node-addon-api@4.3.0: @@ -18485,7 +18603,7 @@ snapshots: log-symbols: 6.0.0 stdin-discarder: 0.2.2 string-width: 7.2.0 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 os-name@6.1.0: dependencies: @@ -19408,6 +19526,15 @@ snapshots: glob: 11.1.0 package-json-from-dist: 1.0.1 + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + robust-predicates@3.0.2: {} rollup@4.40.2: @@ -19569,6 +19696,10 @@ snapshots: dependencies: type-fest: 4.41.0 + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -19891,7 +20022,7 @@ snapshots: dependencies: emoji-regex: 10.4.0 get-east-asian-width: 1.3.0 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 string.prototype.codepointat@0.2.1: {} @@ -20340,6 +20471,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.13.1: {} + type-fest@4.41.0: {} type-is@2.0.1: @@ -21124,7 +21257,7 @@ snapshots: dependencies: ansi-styles: 6.2.1 string-width: 7.2.0 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 wrappy@1.0.2: {} diff --git a/releases/3.37.1-release.png b/releases/3.37.1-release.png new file mode 100644 index 0000000000..586f4821db Binary files /dev/null and b/releases/3.37.1-release.png differ diff --git a/releases/3.38.0-release.png b/releases/3.38.0-release.png new file mode 100644 index 0000000000..46683a26df Binary files /dev/null and b/releases/3.38.0-release.png differ diff --git a/releases/3.38.1-release.png b/releases/3.38.1-release.png new file mode 100644 index 0000000000..d77506be2c Binary files /dev/null and b/releases/3.38.1-release.png differ diff --git a/releases/3.38.2-release.png b/releases/3.38.2-release.png new file mode 100644 index 0000000000..d1e8f06d2f Binary files /dev/null and b/releases/3.38.2-release.png differ diff --git a/releases/3.39.0-release.png b/releases/3.39.0-release.png new file mode 100644 index 0000000000..4f71720928 Binary files /dev/null and b/releases/3.39.0-release.png differ diff --git a/src/__tests__/command-mentions.spec.ts b/src/__tests__/command-mentions.spec.ts index 7ddaf3d092..d309045dc9 100644 --- a/src/__tests__/command-mentions.spec.ts +++ b/src/__tests__/command-mentions.spec.ts @@ -27,7 +27,7 @@ describe("Command Mentions", () => { // Helper function to call parseMentions with required parameters const callParseMentions = async (text: string) => { - return await parseMentions( + const result = await parseMentions( text, "/test/cwd", // cwd mockUrlContentFetcher, // urlContentFetcher @@ -38,6 +38,8 @@ describe("Command Mentions", () => { 50, // maxDiagnosticMessages undefined, // maxReadFileLine ) + // Return just the text for backward compatibility with existing tests + return result.text } describe("parseMentions with command support", () => { diff --git a/src/activate/humanRelay.ts b/src/activate/humanRelay.ts deleted file mode 100644 index ed87026aa7..0000000000 --- a/src/activate/humanRelay.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Callback mapping of human relay response. -const humanRelayCallbacks = new Map void>() - -/** - * Register a callback function for human relay response. - * @param requestId - * @param callback - */ -export const registerHumanRelayCallback = (requestId: string, callback: (response: string | undefined) => void) => - humanRelayCallbacks.set(requestId, callback) - -export const unregisterHumanRelayCallback = (requestId: string) => humanRelayCallbacks.delete(requestId) - -export const handleHumanRelayResponse = (response: { requestId: string; text?: string; cancelled?: boolean }) => { - const callback = humanRelayCallbacks.get(response.requestId) - - if (callback) { - if (response.cancelled) { - callback(undefined) - } else { - callback(response.text) - } - - humanRelayCallbacks.delete(response.requestId) - } -} diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index ad75642406..f02ee8309a 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -9,8 +9,6 @@ import { getCommand } from "../utils/commands" import { ClineProvider } from "../core/webview/ClineProvider" import { ContextProxy } from "../core/config/ContextProxy" import { focusPanel } from "../utils/focusPanel" - -import { registerHumanRelayCallback, unregisterHumanRelayCallback, handleHumanRelayResponse } from "./humanRelay" import { handleNewTask } from "./handleTask" import { CodeIndexManager } from "../services/code-index/manager" import { importSettingsWithFeedback } from "../core/config/importExport" @@ -136,20 +134,6 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt if (!visibleProvider) return visibleProvider.postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) }, - showHumanRelayDialog: (params: { requestId: string; promptText: string }) => { - const panel = getPanel() - - if (panel) { - panel?.webview.postMessage({ - type: "showHumanRelayDialog", - requestId: params.requestId, - promptText: params.promptText, - }) - } - }, - registerHumanRelayCallback: registerHumanRelayCallback, - unregisterHumanRelayCallback: unregisterHumanRelayCallback, - handleHumanRelayResponse: handleHumanRelayResponse, newTask: handleNewTask, setCustomStoragePath: async () => { const { promptForCustomStoragePath } = await import("../utils/storage") diff --git a/src/api/index.ts b/src/api/index.ts index b1bfc58254..2ee882ad72 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -22,7 +22,6 @@ import { VsCodeLmHandler, UnboundHandler, RequestyHandler, - HumanRelayHandler, FakeAIHandler, XAIHandler, GroqHandler, @@ -159,8 +158,6 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new UnboundHandler(options) case "requesty": return new RequestyHandler(options) - case "human-relay": - return new HumanRelayHandler() case "fake-ai": return new FakeAIHandler(options) case "xai": @@ -198,7 +195,6 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { case "baseten": return new BasetenHandler(options) default: - apiProvider satisfies "gemini-cli" | undefined return new AnthropicHandler(options) } } diff --git a/src/api/providers/__tests__/base-provider.spec.ts b/src/api/providers/__tests__/base-provider.spec.ts new file mode 100644 index 0000000000..ced452f5a5 --- /dev/null +++ b/src/api/providers/__tests__/base-provider.spec.ts @@ -0,0 +1,283 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +import type { ModelInfo } from "@roo-code/types" + +import { BaseProvider } from "../base-provider" +import type { ApiStream } from "../../transform/stream" + +// Create a concrete implementation for testing +class TestProvider extends BaseProvider { + createMessage(_systemPrompt: string, _messages: Anthropic.Messages.MessageParam[]): ApiStream { + throw new Error("Not implemented") + } + + getModel(): { id: string; info: ModelInfo } { + return { + id: "test-model", + info: { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + } + + // Expose protected method for testing + public testConvertToolSchemaForOpenAI(schema: any): any { + return this.convertToolSchemaForOpenAI(schema) + } + + // Expose protected method for testing + public testConvertToolsForOpenAI(tools: any[] | undefined): any[] | undefined { + return this.convertToolsForOpenAI(tools) + } +} + +describe("BaseProvider", () => { + let provider: TestProvider + + beforeEach(() => { + provider = new TestProvider() + }) + + describe("convertToolSchemaForOpenAI", () => { + it("should add additionalProperties: false to object schemas", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.additionalProperties).toBe(false) + }) + + it("should add required array with all properties for strict mode", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.required).toEqual(["name", "age"]) + }) + + it("should recursively add additionalProperties: false to nested objects", () => { + const schema = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.additionalProperties).toBe(false) + expect(result.properties.user.additionalProperties).toBe(false) + }) + + it("should recursively add additionalProperties: false to array item objects", () => { + const schema = { + type: "object", + properties: { + users: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.additionalProperties).toBe(false) + expect(result.properties.users.items.additionalProperties).toBe(false) + }) + + it("should handle deeply nested objects", () => { + const schema = { + type: "object", + properties: { + level1: { + type: "object", + properties: { + level2: { + type: "object", + properties: { + level3: { + type: "object", + properties: { + value: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.additionalProperties).toBe(false) + expect(result.properties.level1.additionalProperties).toBe(false) + expect(result.properties.level1.properties.level2.additionalProperties).toBe(false) + expect(result.properties.level1.properties.level2.properties.level3.additionalProperties).toBe(false) + }) + + it("should convert nullable types to non-nullable", () => { + const schema = { + type: "object", + properties: { + name: { type: ["string", "null"] }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.properties.name.type).toBe("string") + }) + + it("should return non-object schemas unchanged", () => { + const schema = { type: "string" } + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result).toEqual(schema) + }) + + it("should return null/undefined unchanged", () => { + expect(provider.testConvertToolSchemaForOpenAI(null)).toBeNull() + expect(provider.testConvertToolSchemaForOpenAI(undefined)).toBeUndefined() + }) + + it("should handle empty properties object", () => { + const schema = { + type: "object", + properties: {}, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.additionalProperties).toBe(false) + expect(result.required).toEqual([]) + }) + }) + + describe("convertToolsForOpenAI", () => { + it("should return undefined for undefined input", () => { + const result = provider.testConvertToolsForOpenAI(undefined) + expect(result).toBeUndefined() + }) + + it("should set strict: true for non-MCP tools", () => { + const tools = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: {} }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools) + + expect(result?.[0].function.strict).toBe(true) + }) + + it("should set strict: false for MCP tools (mcp-- prefix)", () => { + const tools = [ + { + type: "function", + function: { + name: "mcp--github--get_me", + description: "Get current user", + parameters: { type: "object", properties: {} }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools) + + expect(result?.[0].function.strict).toBe(false) + }) + + it("should apply schema conversion to non-MCP tools", () => { + const tools = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + }, + }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools) + + expect(result?.[0].function.parameters.additionalProperties).toBe(false) + expect(result?.[0].function.parameters.required).toEqual(["path"]) + }) + + it("should not apply schema conversion to MCP tools in base-provider", () => { + // Note: In base-provider, MCP tools are passed through unchanged + // The openai-native provider has its own handling for MCP tools + const tools = [ + { + type: "function", + function: { + name: "mcp--github--get_me", + description: "Get current user", + parameters: { + type: "object", + properties: { + token: { type: "string" }, + }, + required: ["token"], + }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools) + + // MCP tools pass through original parameters in base-provider + expect(result?.[0].function.parameters.additionalProperties).toBeUndefined() + }) + + it("should preserve non-function tools unchanged", () => { + const tools = [ + { + type: "other_type", + data: "some data", + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools) + + expect(result?.[0]).toEqual(tools[0]) + }) + }) +}) diff --git a/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts b/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts index 7fe7255f5b..fe16ea89eb 100644 --- a/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts +++ b/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts @@ -122,7 +122,7 @@ describe("AwsBedrockHandler with invokedModelId", () => { trace: { promptRouter: { invokedModelId: - "arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-2-1-v1:0", + "arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-3-opus-20240229-v1:0", usage: { inputTokens: 150, outputTokens: 250, @@ -162,12 +162,12 @@ describe("AwsBedrockHandler with invokedModelId", () => { } // Verify that getModelById was called with the id, not the full arn - expect(getModelByIdSpy).toHaveBeenCalledWith("anthropic.claude-2-1-v1:0", "inference-profile") + expect(getModelByIdSpy).toHaveBeenCalledWith("anthropic.claude-3-opus-20240229-v1:0", "inference-profile") // Verify that getModel returns the updated model info const costModel = handler.getModel() //expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20240620-v1:0") - expect(costModel.info.inputPrice).toBe(8) + expect(costModel.info.inputPrice).toBe(15) // Verify that a usage event was emitted after updating the costModelConfig const usageEvents = events.filter((event) => event.type === "usage") diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index 9b837fef60..ac5c4396f1 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -115,6 +115,31 @@ describe("FireworksHandler", () => { ) }) + it("should return Kimi K2 Thinking model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 16000, + contextWindow: 256000, + supportsImages: false, + supportsPromptCache: true, + supportsNativeTools: true, + supportsTemperature: true, + preserveReasoning: true, + defaultTemperature: 1.0, + inputPrice: 0.6, + outputPrice: 2.5, + cacheReadsPrice: 0.15, + }), + ) + }) + it("should return MiniMax M2 model with correct configuration", () => { const testModelId: FireworksModelId = "accounts/fireworks/models/minimax-m2" const handlerWithModel = new FireworksHandler({ @@ -424,16 +449,85 @@ describe("FireworksHandler", () => { ) }) - it("should use default temperature of 0.5", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct" + it("should use provider default temperature of 0.5 for models without defaultTemperature", async () => { + const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct" const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, + apiModelId: modelId, fireworksApiKey: "test-fireworks-api-key", }) - const model = handlerWithModel.getModel() - // The temperature is set in the constructor as defaultTemperature: 0.5 - // This test verifies the handler is configured with the correct default temperature - expect(handlerWithModel).toBeDefined() + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + const messageGenerator = handlerWithModel.createMessage("system", []) + await messageGenerator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + }), + undefined, + ) + }) + + it("should use model defaultTemperature (1.0) over provider default (0.5) for kimi-k2-thinking", async () => { + const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking" + const handlerWithModel = new FireworksHandler({ + apiModelId: modelId, + fireworksApiKey: "test-fireworks-api-key", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + const messageGenerator = handlerWithModel.createMessage("system", []) + await messageGenerator.next() + + // Model's defaultTemperature (1.0) should take precedence over provider's default (0.5) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 1.0, + }), + undefined, + ) + }) + + it("should use user-specified temperature over model and provider defaults", async () => { + const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking" + const handlerWithModel = new FireworksHandler({ + apiModelId: modelId, + fireworksApiKey: "test-fireworks-api-key", + modelTemperature: 0.7, + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + const messageGenerator = handlerWithModel.createMessage("system", []) + await messageGenerator.next() + + // User-specified temperature should take precedence over everything + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + undefined, + ) }) it("should handle empty response in completePrompt", async () => { diff --git a/src/api/providers/__tests__/openai-native-tools.spec.ts b/src/api/providers/__tests__/openai-native-tools.spec.ts new file mode 100644 index 0000000000..7be4814b63 --- /dev/null +++ b/src/api/providers/__tests__/openai-native-tools.spec.ts @@ -0,0 +1,296 @@ +import OpenAI from "openai" + +import { OpenAiHandler } from "../openai" +import { OpenAiNativeHandler } from "../openai-native" +import type { ApiHandlerOptions } from "../../../shared/api" + +describe("OpenAiHandler native tools", () => { + it("includes tools in request when custom model info lacks supportsNativeTools (regression test)", async () => { + const mockCreate = vi.fn().mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) + + // Set openAiCustomModelInfo WITHOUT supportsNativeTools to simulate + // a user-provided custom model info that doesn't specify native tool support. + // The getModel() fix should merge NATIVE_TOOL_DEFAULTS to ensure + // supportsNativeTools defaults to true. + const handler = new OpenAiHandler({ + openAiApiKey: "test-key", + openAiBaseUrl: "https://example.com/v1", + openAiModelId: "test-model", + openAiCustomModelInfo: { + maxTokens: 4096, + contextWindow: 128000, + }, + } as unknown as import("../../../shared/api").ApiHandlerOptions) + + // Patch the OpenAI client call + const mockClient = { + chat: { + completions: { + create: mockCreate, + }, + }, + } as unknown as OpenAI + ;(handler as unknown as { client: OpenAI }).client = mockClient + + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "test_tool", + description: "test", + parameters: { type: "object", properties: {} }, + }, + }, + ] + + // Mimic the behavior in Task.attemptApiRequest() where tools are only + // included when modelInfo.supportsNativeTools is true. This is the + // actual regression path being tested - without the getModel() fix, + // supportsNativeTools would be undefined and tools wouldn't be passed. + const modelInfo = handler.getModel().info + const supportsNativeTools = modelInfo.supportsNativeTools ?? false + + const stream = handler.createMessage("system", [], { + taskId: "test-task-id", + ...(supportsNativeTools && { tools }), + ...(supportsNativeTools && { toolProtocol: "native" as const }), + }) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "test_tool" }), + }), + ]), + parallel_tool_calls: false, + }), + expect.anything(), + ) + }) +}) + +describe("OpenAiNativeHandler MCP tool schema handling", () => { + it("should add additionalProperties: false to MCP tools while keeping strict: false", async () => { + let capturedRequestBody: any + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) + + // Mock the responses API call + const mockClient = { + responses: { + create: vi.fn().mockImplementation((body: any) => { + capturedRequestBody = body + return { + [Symbol.asyncIterator]: async function* () { + yield { + type: "response.done", + response: { + output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], + usage: { input_tokens: 10, output_tokens: 5 }, + }, + } + }, + } + }), + }, + } + ;(handler as any).client = mockClient + + const mcpTools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "mcp--github--get_me", + description: "Get current GitHub user", + parameters: { + type: "object", + properties: { + token: { type: "string", description: "API token" }, + }, + required: ["token"], + }, + }, + }, + ] + + const stream = handler.createMessage("system prompt", [], { + taskId: "test-task-id", + tools: mcpTools, + toolProtocol: "native" as const, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + // Verify the request body + expect(capturedRequestBody.tools).toBeDefined() + expect(capturedRequestBody.tools.length).toBe(1) + + const tool = capturedRequestBody.tools[0] + expect(tool.name).toBe("mcp--github--get_me") + expect(tool.strict).toBe(false) // MCP tools should have strict: false + expect(tool.parameters.additionalProperties).toBe(false) // Should have additionalProperties: false + expect(tool.parameters.required).toEqual(["token"]) // Should preserve original required array + }) + + it("should add additionalProperties: false and required array to non-MCP tools with strict: true", async () => { + let capturedRequestBody: any + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) + + // Mock the responses API call + const mockClient = { + responses: { + create: vi.fn().mockImplementation((body: any) => { + capturedRequestBody = body + return { + [Symbol.asyncIterator]: async function* () { + yield { + type: "response.done", + response: { + output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], + usage: { input_tokens: 10, output_tokens: 5 }, + }, + } + }, + } + }), + }, + } + ;(handler as any).client = mockClient + + const regularTools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file from the filesystem", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + encoding: { type: "string", description: "File encoding" }, + }, + }, + }, + }, + ] + + const stream = handler.createMessage("system prompt", [], { + taskId: "test-task-id", + tools: regularTools, + toolProtocol: "native" as const, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + // Verify the request body + expect(capturedRequestBody.tools).toBeDefined() + expect(capturedRequestBody.tools.length).toBe(1) + + const tool = capturedRequestBody.tools[0] + expect(tool.name).toBe("read_file") + expect(tool.strict).toBe(true) // Non-MCP tools should have strict: true + expect(tool.parameters.additionalProperties).toBe(false) // Should have additionalProperties: false + expect(tool.parameters.required).toEqual(["path", "encoding"]) // Should have all properties as required + }) + + it("should recursively add additionalProperties: false to nested objects in MCP tools", async () => { + let capturedRequestBody: any + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) + + // Mock the responses API call + const mockClient = { + responses: { + create: vi.fn().mockImplementation((body: any) => { + capturedRequestBody = body + return { + [Symbol.asyncIterator]: async function* () { + yield { + type: "response.done", + response: { + output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], + usage: { input_tokens: 10, output_tokens: 5 }, + }, + } + }, + } + }), + }, + } + ;(handler as any).client = mockClient + + const mcpToolsWithNestedObjects: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "mcp--linear--create_issue", + description: "Create a Linear issue", + parameters: { + type: "object", + properties: { + title: { type: "string" }, + metadata: { + type: "object", + properties: { + priority: { type: "number" }, + labels: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ] + + const stream = handler.createMessage("system prompt", [], { + taskId: "test-task-id", + tools: mcpToolsWithNestedObjects, + toolProtocol: "native" as const, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + // Verify the request body + const tool = capturedRequestBody.tools[0] + expect(tool.strict).toBe(false) // MCP tool should have strict: false + expect(tool.parameters.additionalProperties).toBe(false) // Root level + expect(tool.parameters.properties.metadata.additionalProperties).toBe(false) // Nested object + expect(tool.parameters.properties.metadata.properties.labels.items.additionalProperties).toBe(false) // Array items + }) +}) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 1ebdd68494..8875df9a47 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -268,38 +268,11 @@ describe("OpenRouterHandler", () => { stream_options: { include_usage: true }, temperature: 0, top_p: undefined, - transforms: ["middle-out"], }), { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, ) }) - it("supports the middle-out transform", async () => { - const handler = new OpenRouterHandler({ - ...mockOptions, - openRouterUseMiddleOutTransform: true, - }) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: "test-id", - choices: [{ delta: { content: "test response" } }], - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(mockStream) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - - await handler.createMessage("test", []).next() - - expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ transforms: ["middle-out"] }), { - headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" }, - }) - }) - it("adds cache control for supported models", async () => { const handler = new OpenRouterHandler({ ...mockOptions, diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 5aee7267b3..a2a55cdc10 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -84,7 +84,7 @@ export abstract class BaseOpenAiCompatibleProvider format: "openai", }) ?? undefined - const temperature = this.options.modelTemperature ?? this.defaultTemperature + const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model, diff --git a/src/api/providers/base-provider.ts b/src/api/providers/base-provider.ts index 64d99b3f0c..a6adeeadbd 100644 --- a/src/api/providers/base-provider.ts +++ b/src/api/providers/base-provider.ts @@ -55,6 +55,7 @@ export abstract class BaseProvider implements ApiHandler { * Converts tool schemas to be compatible with OpenAI's strict mode by: * - Ensuring all properties are in the required array (strict mode requirement) * - Converting nullable types (["type", "null"]) to non-nullable ("type") + * - Adding additionalProperties: false to all object schemas (required by OpenAI Responses API) * - Recursively processing nested objects and arrays * * This matches the behavior of ensureAllRequired in openai-native.ts @@ -66,6 +67,12 @@ export abstract class BaseProvider implements ApiHandler { const result = { ...schema } + // OpenAI Responses API requires additionalProperties: false on all object schemas + // Only add if not already set to false (to avoid unnecessary mutations) + if (result.additionalProperties !== false) { + result.additionalProperties = false + } + if (result.properties) { const allKeys = Object.keys(result.properties) // OpenAI strict mode requires ALL properties to be in required array diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index cdd1cb3beb..f2bccc329c 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -122,153 +122,184 @@ export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler { // Reset per-request state that we persist into apiConversationHistory this.lastThinkingSignature = undefined - // Get access token from OAuth manager - const accessToken = await claudeCodeOAuthManager.getAccessToken() - - if (!accessToken) { - throw new Error( + const buildNotAuthenticatedError = () => + new Error( t("common:errors.claudeCode.notAuthenticated", { defaultValue: "Not authenticated with Claude Code. Please sign in using the Claude Code OAuth flow.", }), ) - } - // Get user email for generating user_id metadata - const email = await claudeCodeOAuthManager.getEmail() + async function* streamOnce(this: ClaudeCodeHandler, accessToken: string): ApiStream { + // Get user email for generating user_id metadata + const email = await claudeCodeOAuthManager.getEmail() - const model = this.getModel() + const model = this.getModel() - // Validate that the model ID is a valid ClaudeCodeModelId - const modelId = Object.hasOwn(claudeCodeModels, model.id) - ? (model.id as ClaudeCodeModelId) - : claudeCodeDefaultModelId + // Validate that the model ID is a valid ClaudeCodeModelId + const modelId = Object.hasOwn(claudeCodeModels, model.id) + ? (model.id as ClaudeCodeModelId) + : claudeCodeDefaultModelId - // Generate user_id metadata in the format required by Claude Code API - const userId = generateUserId(email || undefined) + // Generate user_id metadata in the format required by Claude Code API + const userId = generateUserId(email || undefined) - // Convert OpenAI tools to Anthropic format if provided and protocol is native - // Exclude tools when tool_choice is "none" since that means "don't use tools" - const shouldIncludeNativeTools = - metadata?.tools && - metadata.tools.length > 0 && - metadata?.toolProtocol !== "xml" && - metadata?.tool_choice !== "none" + // Convert OpenAI tools to Anthropic format if provided and protocol is native + // Exclude tools when tool_choice is "none" since that means "don't use tools" + const shouldIncludeNativeTools = + metadata?.tools && + metadata.tools.length > 0 && + metadata?.toolProtocol !== "xml" && + metadata?.tool_choice !== "none" - const anthropicTools = shouldIncludeNativeTools ? convertOpenAIToolsToAnthropic(metadata.tools!) : undefined + const anthropicTools = shouldIncludeNativeTools ? convertOpenAIToolsToAnthropic(metadata.tools!) : undefined - const anthropicToolChoice = shouldIncludeNativeTools - ? convertOpenAIToolChoice(metadata.tool_choice, metadata.parallelToolCalls) - : undefined + const anthropicToolChoice = shouldIncludeNativeTools + ? convertOpenAIToolChoice(metadata.tool_choice, metadata.parallelToolCalls) + : undefined - // Determine reasoning effort and thinking configuration - const reasoningLevel = this.getReasoningEffort(model.info) + // Determine reasoning effort and thinking configuration + const reasoningLevel = this.getReasoningEffort(model.info) - let thinking: ThinkingConfig - // With interleaved thinking (enabled via beta header), budget_tokens can exceed max_tokens - // as the token limit becomes the entire context window. We use the model's maxTokens. - // See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking - const maxTokens = model.info.maxTokens ?? 16384 + let thinking: ThinkingConfig + // With interleaved thinking (enabled via beta header), budget_tokens can exceed max_tokens + // as the token limit becomes the entire context window. We use the model's maxTokens. + // See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking + const maxTokens = model.info.maxTokens ?? 16384 - if (reasoningLevel) { - // Use thinking mode with budget_tokens from config - const config = claudeCodeReasoningConfig[reasoningLevel] - thinking = { - type: "enabled", - budget_tokens: config.budgetTokens, + if (reasoningLevel) { + // Use thinking mode with budget_tokens from config + const config = claudeCodeReasoningConfig[reasoningLevel] + thinking = { + type: "enabled", + budget_tokens: config.budgetTokens, + } + } else { + // Explicitly disable thinking + thinking = { type: "disabled" } + } + + // Create streaming request using OAuth + const stream = createStreamingMessage({ + accessToken, + model: modelId, + systemPrompt, + messages, + maxTokens, + thinking, + tools: anthropicTools, + toolChoice: anthropicToolChoice, + metadata: { + user_id: userId, + }, + }) + + // Track usage for cost calculation + let inputTokens = 0 + let outputTokens = 0 + let cacheReadTokens = 0 + let cacheWriteTokens = 0 + + for await (const chunk of stream) { + switch (chunk.type) { + case "text": + yield { + type: "text", + text: chunk.text, + } + break + + case "reasoning": + yield { + type: "reasoning", + text: chunk.text, + } + break + + case "thinking_complete": + // Capture the signature for persistence in api_conversation_history + // This enables tool use continuations where thinking blocks must be passed back + if (chunk.signature) { + this.lastThinkingSignature = chunk.signature + } + // Emit a complete thinking block with signature + // This is critical for interleaved thinking with tool use + // The signature must be included when passing thinking blocks back to the API + yield { + type: "reasoning", + text: chunk.thinking, + signature: chunk.signature, + } + break + + case "tool_call_partial": + yield { + type: "tool_call_partial", + index: chunk.index, + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + } + break + + case "usage": { + inputTokens = chunk.inputTokens + outputTokens = chunk.outputTokens + cacheReadTokens = chunk.cacheReadTokens || 0 + cacheWriteTokens = chunk.cacheWriteTokens || 0 + + // Claude Code is subscription-based, no per-token cost + const usageChunk: ApiStreamUsageChunk = { + type: "usage", + inputTokens, + outputTokens, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + totalCost: 0, + } + + yield usageChunk + break + } + + case "error": + throw new Error(chunk.error) + } } - } else { - // Explicitly disable thinking - thinking = { type: "disabled" } } - // Create streaming request using OAuth - const stream = createStreamingMessage({ - accessToken, - model: modelId, - systemPrompt, - messages, - maxTokens, - thinking, - tools: anthropicTools, - toolChoice: anthropicToolChoice, - metadata: { - user_id: userId, - }, - }) + // Get access token from OAuth manager + let accessToken = await claudeCodeOAuthManager.getAccessToken() + if (!accessToken) { + throw buildNotAuthenticatedError() + } - // Track usage for cost calculation - let inputTokens = 0 - let outputTokens = 0 - let cacheReadTokens = 0 - let cacheWriteTokens = 0 + // Try the request with at most one force-refresh retry on auth failure + for (let attempt = 0; attempt < 2; attempt++) { + try { + yield* streamOnce.call(this, accessToken) + return + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const isAuthFailure = /unauthorized|invalid token|not authenticated|authentication/i.test(message) - for await (const chunk of stream) { - switch (chunk.type) { - case "text": - yield { - type: "text", - text: chunk.text, - } - break - - case "reasoning": - yield { - type: "reasoning", - text: chunk.text, - } - break - - case "thinking_complete": - // Capture the signature for persistence in api_conversation_history - // This enables tool use continuations where thinking blocks must be passed back - if (chunk.signature) { - this.lastThinkingSignature = chunk.signature - } - // Emit a complete thinking block with signature - // This is critical for interleaved thinking with tool use - // The signature must be included when passing thinking blocks back to the API - yield { - type: "reasoning", - text: chunk.thinking, - signature: chunk.signature, - } - break - - case "tool_call_partial": - yield { - type: "tool_call_partial", - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - } - break - - case "usage": { - inputTokens = chunk.inputTokens - outputTokens = chunk.outputTokens - cacheReadTokens = chunk.cacheReadTokens || 0 - cacheWriteTokens = chunk.cacheWriteTokens || 0 - - // Claude Code is subscription-based, no per-token cost - const usageChunk: ApiStreamUsageChunk = { - type: "usage", - inputTokens, - outputTokens, - cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, - cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, - totalCost: 0, - } - - yield usageChunk - break + // Only retry on auth failure during first attempt + const canRetry = attempt === 0 && isAuthFailure + if (!canRetry) { + throw error } - case "error": - throw new Error(chunk.error) + // Force refresh the token for retry + const refreshed = await claudeCodeOAuthManager.forceRefreshAccessToken() + if (!refreshed) { + throw buildNotAuthenticatedError() + } + accessToken = refreshed } } + + // Unreachable: loop always returns on success or throws on failure + throw buildNotAuthenticatedError() } getModel(): { id: string; info: ModelInfo } { diff --git a/src/api/providers/human-relay.ts b/src/api/providers/human-relay.ts deleted file mode 100644 index 54446bd362..0000000000 --- a/src/api/providers/human-relay.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import * as vscode from "vscode" - -import type { ModelInfo } from "@roo-code/types" - -import { getCommand } from "../../utils/commands" -import { ApiStream } from "../transform/stream" - -import type { ApiHandler, SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" - -/** - * Human Relay API processor - * This processor does not directly call the API, but interacts with the model through human operations copy and paste. - */ -export class HumanRelayHandler implements ApiHandler, SingleCompletionHandler { - countTokens(_content: Array): Promise { - return Promise.resolve(0) - } - - /** - * Create a message processing flow, display a dialog box to request human assistance - * @param systemPrompt System prompt words - * @param messages Message list - * @param metadata Optional metadata - */ - async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - // Get the most recent user message - const latestMessage = messages[messages.length - 1] - - if (!latestMessage) { - throw new Error("No message to relay") - } - - // If it is the first message, splice the system prompt word with the user message - let promptText = "" - if (messages.length === 1) { - promptText = `${systemPrompt}\n\n${getMessageContent(latestMessage)}` - } else { - promptText = getMessageContent(latestMessage) - } - - // Copy to clipboard - await vscode.env.clipboard.writeText(promptText) - - // A dialog box pops up to request user action - const response = await showHumanRelayDialog(promptText) - - if (!response) { - // The user canceled the operation - throw new Error("Human relay operation cancelled") - } - - // Return to the user input reply - yield { type: "text", text: response } - } - - /** - * Get model information - */ - getModel(): { id: string; info: ModelInfo } { - // Human relay does not depend on a specific model, here is a default configuration - return { - id: "human-relay", - info: { - maxTokens: 16384, - contextWindow: 100000, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Calling web-side AI model through human relay", - }, - } - } - - /** - * Implementation of a single prompt - * @param prompt Prompt content - */ - async completePrompt(prompt: string): Promise { - // Copy to clipboard - await vscode.env.clipboard.writeText(prompt) - - // A dialog box pops up to request user action - const response = await showHumanRelayDialog(prompt) - - if (!response) { - throw new Error("Human relay operation cancelled") - } - - return response - } -} - -/** - * Extract text content from message object - * @param message - */ -function getMessageContent(message: Anthropic.Messages.MessageParam): string { - if (typeof message.content === "string") { - return message.content - } else if (Array.isArray(message.content)) { - return message.content - .filter((item) => item.type === "text") - .map((item) => (item.type === "text" ? item.text : "")) - .join("\n") - } - return "" -} -/** - * Displays the human relay dialog and waits for user response. - * @param promptText The prompt text that needs to be copied. - * @returns The user's input response or undefined (if canceled). - */ -async function showHumanRelayDialog(promptText: string): Promise { - return new Promise((resolve) => { - // Create a unique request ID. - const requestId = Date.now().toString() - - // Register a global callback function. - vscode.commands.executeCommand( - getCommand("registerHumanRelayCallback"), - requestId, - (response: string | undefined) => resolve(response), - ) - - // Open the dialog box directly using the current panel. - vscode.commands.executeCommand(getCommand("showHumanRelayDialog"), { requestId, promptText }) - }) -} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 23ef40ee13..fe9388962f 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -11,7 +11,6 @@ export { FakeAIHandler } from "./fake-ai" export { GeminiHandler } from "./gemini" export { GroqHandler } from "./groq" export { HuggingFaceHandler } from "./huggingface" -export { HumanRelayHandler } from "./human-relay" export { IOIntelligenceHandler } from "./io-intelligence" export { LiteLLMHandler } from "./lite-llm" export { LmStudioHandler } from "./lm-studio" diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 8f9cc2297f..58a62497f7 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -196,6 +196,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio const result = { ...schema } + // OpenAI Responses API requires additionalProperties: false on all object schemas + // Only add if not already set to false (to avoid unnecessary mutations) + if (result.additionalProperties !== false) { + result.additionalProperties = false + } + if (result.properties) { const allKeys = Object.keys(result.properties) result.required = allKeys @@ -219,6 +225,42 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio return result } + // Adds additionalProperties: false to all object schemas recursively + // without modifying required array. Used for MCP tools with strict: false + // to comply with OpenAI Responses API requirements. + const ensureAdditionalPropertiesFalse = (schema: any): any => { + if (!schema || typeof schema !== "object" || schema.type !== "object") { + return schema + } + + const result = { ...schema } + + // OpenAI Responses API requires additionalProperties: false on all object schemas + // Only add if not already set to false (to avoid unnecessary mutations) + if (result.additionalProperties !== false) { + result.additionalProperties = false + } + + if (result.properties) { + // Recursively process nested objects + const newProps = { ...result.properties } + for (const key of Object.keys(result.properties)) { + const prop = newProps[key] + if (prop && prop.type === "object") { + newProps[key] = ensureAdditionalPropertiesFalse(prop) + } else if (prop && prop.type === "array" && prop.items?.type === "object") { + newProps[key] = { + ...prop, + items: ensureAdditionalPropertiesFalse(prop.items), + } + } + } + result.properties = newProps + } + + return result + } + // Build a request body for the OpenAI Responses API. // Ensure we explicitly pass max_output_tokens based on Roo's reserved model response calculation // so requests do not default to very large limits (e.g., 120k). @@ -295,12 +337,15 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio .map((tool) => { // MCP tools use the 'mcp--' prefix - disable strict mode for them // to preserve optional parameters from the MCP server schema + // But we still need to add additionalProperties: false for OpenAI Responses API const isMcp = isMcpTool(tool.function.name) return { type: "function", name: tool.function.name, description: tool.function.description, - parameters: isMcp ? tool.function.parameters : ensureAllRequired(tool.function.parameters), + parameters: isMcp + ? ensureAdditionalPropertiesFalse(tool.function.parameters) + : ensureAllRequired(tool.function.parameters), strict: !isMcp, } }), diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index d6f50d0269..860fb76a6f 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -6,6 +6,7 @@ import { type ModelInfo, azureOpenAiDefaultApiVersion, openAiModelInfoSaneDefaults, + NATIVE_TOOL_DEFAULTS, DEEP_SEEK_DEFAULT_TEMPERATURE, OPENAI_AZURE_AI_INFERENCE_PATH, } from "@roo-code/types" @@ -291,7 +292,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl override getModel() { const id = this.options.openAiModelId ?? "" - const info = this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults + // Ensure OpenAI-compatible models default to supporting native tool calling. + // This is required for [`Task.attemptApiRequest()`](src/core/task/Task.ts:3817) to + // include tool definitions in the request. + const info: ModelInfo = { + ...NATIVE_TOOL_DEFAULTS, + ...(this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults), + } const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) return { id, info, ...params } } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 5b8c29c337..4a8a078018 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -291,8 +291,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } - const transforms = (this.options.openRouterUseMiddleOutTransform ?? true) ? ["middle-out"] : undefined - // https://openrouter.ai/docs/transforms const completionParams: OpenRouterChatCompletionParams = { model: modelId, @@ -311,7 +309,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH allow_fallbacks: false, }, }), - ...(transforms && { transforms }), ...(reasoning && { reasoning }), ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), @@ -361,7 +358,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } let lastUsage: CompletionUsage | undefined = undefined - // Accumulator for reasoning_details: accumulate text by type-index key + // Accumulator for reasoning_details FROM the API. + // We preserve the original shape of reasoning_details to prevent malformed responses. const reasoningDetailsAccumulator = new Map< string, { @@ -376,6 +374,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } >() + // Track whether we've yielded displayable text from reasoning_details. + // When reasoning_details has displayable content (reasoning.text or reasoning.summary), + // we skip yielding the top-level reasoning field to avoid duplicate display. + let hasYieldedReasoningFromDetails = false + for await (const chunk of stream) { // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. if ("error" in chunk) { @@ -438,22 +441,28 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } // Yield text for display (still fragmented for live streaming) + // Only reasoning.text and reasoning.summary have displayable content + // reasoning.encrypted is intentionally skipped as it contains redacted content let reasoningText: string | undefined if (detail.type === "reasoning.text" && typeof detail.text === "string") { reasoningText = detail.text } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { reasoningText = detail.summary } - // Note: reasoning.encrypted types are intentionally skipped as they contain redacted content if (reasoningText) { + hasYieldedReasoningFromDetails = true yield { type: "reasoning", text: reasoningText } } } - } else if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - // Handle legacy reasoning format - only if reasoning_details is not present - // See: https://openrouter.ai/docs/use-cases/reasoning-tokens - yield { type: "reasoning", text: delta.reasoning } + } + + // Handle top-level reasoning field for UI display. + // Skip if we've already yielded from reasoning_details to avoid duplicate display. + if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + if (!hasYieldedReasoningFromDetails) { + yield { type: "reasoning", text: delta.reasoning } + } } // Emit raw tool call chunks - NativeToolCallParser handles state management @@ -488,7 +497,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } - // After streaming completes, store the accumulated reasoning_details + // After streaming completes, store ONLY the reasoning_details we received from the API. if (reasoningDetailsAccumulator.size > 0) { this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values()) } diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index ebc174cf46..bfd99750bf 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -100,13 +100,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { model, max_tokens, temperature, - // Enable mergeToolResultText to merge environment_details and other text content - // after tool_results into the last tool message. This prevents reasoning/thinking - // models from dropping reasoning_content when they see a user message after tool results. - messages: [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), - ], + messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], stream: true, stream_options: { include_usage: true }, ...(reasoning && { reasoning }), @@ -146,7 +140,8 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { const stream = await this.createStream(systemPrompt, messages, metadata, { headers }) let lastUsage: RooUsage | undefined = undefined - // Accumulator for reasoning_details: accumulate text by type-index key + // Accumulator for reasoning_details FROM the API. + // We preserve the original shape of reasoning_details to prevent malformed responses. const reasoningDetailsAccumulator = new Map< string, { @@ -161,6 +156,11 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } >() + // Track whether we've yielded displayable text from reasoning_details. + // When reasoning_details has displayable content (reasoning.text or reasoning.summary), + // we skip yielding the top-level reasoning field to avoid duplicate display. + let hasYieldedReasoningFromDetails = false + for await (const chunk of stream) { const delta = chunk.choices[0]?.delta const finishReason = chunk.choices[0]?.finish_reason @@ -223,29 +223,32 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } // Yield text for display (still fragmented for live streaming) + // Only reasoning.text and reasoning.summary have displayable content + // reasoning.encrypted is intentionally skipped as it contains redacted content let reasoningText: string | undefined if (detail.type === "reasoning.text" && typeof detail.text === "string") { reasoningText = detail.text } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { reasoningText = detail.summary } - // Note: reasoning.encrypted types are intentionally skipped as they contain redacted content if (reasoningText) { + hasYieldedReasoningFromDetails = true yield { type: "reasoning", text: reasoningText } } } - } else if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - // Handle legacy reasoning format - only if reasoning_details is not present - yield { - type: "reasoning", - text: delta.reasoning, + } + + // Handle top-level reasoning field for UI display. + // Skip if we've already yielded from reasoning_details to avoid duplicate display. + if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + if (!hasYieldedReasoningFromDetails) { + yield { type: "reasoning", text: delta.reasoning } } } else if ("reasoning_content" in delta && typeof delta.reasoning_content === "string") { // Also check for reasoning_content for backward compatibility - yield { - type: "reasoning", - text: delta.reasoning_content, + if (!hasYieldedReasoningFromDetails) { + yield { type: "reasoning", text: delta.reasoning_content } } } @@ -282,7 +285,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } - // After streaming completes, store the accumulated reasoning_details + // After streaming completes, store ONLY the reasoning_details we received from the API. if (reasoningDetailsAccumulator.size > 0) { this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values()) } diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index 29fd712c84..2e7f61c9f3 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -401,4 +401,371 @@ describe("convertToOpenAiMessages", () => { expect(openAiMessages[0].role).toBe("user") }) }) + + describe("reasoning_details transformation", () => { + it("should preserve reasoning_details when assistant content is a string", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: "Why don't scientists trust atoms? Because they make up everything!", + reasoning_details: [ + { + type: "reasoning.summary", + summary: "The user asked for a joke.", + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.encrypted", + data: "encrypted_data_here", + id: "rs_abc", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + expect(assistantMessage.role).toBe("assistant") + expect(assistantMessage.content).toBe("Why don't scientists trust atoms? Because they make up everything!") + expect(assistantMessage.reasoning_details).toHaveLength(2) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") + expect(assistantMessage.reasoning_details[1].id).toBe("rs_abc") + }) + + it("should strip id from openai-responses-v1 blocks even when assistant content is a string", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: "Ok.", + reasoning_details: [ + { + type: "reasoning.summary", + id: "rs_should_be_stripped", + format: "openai-responses-v1", + index: 0, + summary: "internal", + data: "gAAAAA...", + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") + expect(assistantMessage.reasoning_details[0].id).toBeUndefined() + }) + + it("should pass through all reasoning_details without extracting to top-level reasoning", () => { + // This simulates the stored format after receiving from xAI/Roo API + // The provider (roo.ts) now consolidates all reasoning into reasoning_details + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "I'll help you with that." }], + reasoning_details: [ + { + type: "reasoning.summary", + summary: '\n\n## Reviewing task progress', + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.encrypted", + data: "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", + id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + expect(assistantMessage.role).toBe("assistant") + + // Should NOT have top-level reasoning field - we only use reasoning_details now + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through all reasoning_details preserving all fields + expect(assistantMessage.reasoning_details).toHaveLength(2) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + expect(assistantMessage.reasoning_details[0].summary).toBe( + '\n\n## Reviewing task progress', + ) + expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") + expect(assistantMessage.reasoning_details[1].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") + expect(assistantMessage.reasoning_details[1].data).toBe( + "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", + ) + }) + + it("should strip id from openai-responses-v1 blocks to avoid 404 errors (store: false)", () => { + // IMPORTANT: OpenAI's API returns a 404 error when we send back an `id` for + // reasoning blocks with format "openai-responses-v1" because we don't use + // `store: true` (we handle conversation state client-side). The error message is: + // "'{id}' not found. Items are not persisted when `store` is set to false." + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "call_Tb4KVEmEpEAA8W1QcxjyD5Nh", + name: "attempt_completion", + input: { + result: "Why did the developer go broke?\n\nBecause they used up all their cache.", + }, + }, + ], + reasoning_details: [ + { + type: "reasoning.summary", + id: "rs_0de1fb80387fb36501694ad8d71c3081949934e6bb177e5ec5", + format: "openai-responses-v1", + index: 0, + summary: "It looks like I need to make sure I'm using the tool every time.", + data: "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field - we only use reasoning_details now + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through reasoning_details preserving most fields BUT stripping id + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + // id should be STRIPPED for openai-responses-v1 format to avoid 404 errors + expect(assistantMessage.reasoning_details[0].id).toBeUndefined() + expect(assistantMessage.reasoning_details[0].summary).toBe( + "It looks like I need to make sure I'm using the tool every time.", + ) + expect(assistantMessage.reasoning_details[0].data).toBe( + "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", + ) + expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") + + // Should have tool_calls + expect(assistantMessage.tool_calls).toHaveLength(1) + expect(assistantMessage.tool_calls[0].id).toBe("call_Tb4KVEmEpEAA8W1QcxjyD5Nh") + }) + + it("should preserve id for non-openai-responses-v1 formats (e.g., xai-responses-v1)", () => { + // For other formats like xai-responses-v1, we should preserve the id + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response" }], + reasoning_details: [ + { + type: "reasoning.encrypted", + id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", + format: "xai-responses-v1", + data: "encrypted_data_here", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should preserve id for xai-responses-v1 format + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") + expect(assistantMessage.reasoning_details[0].format).toBe("xai-responses-v1") + }) + + it("should handle assistant messages with tool_calls and reasoning_details", () => { + // This simulates a message with both tool calls and reasoning + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "call_62462410", + name: "read_file", + input: { files: [{ path: "alphametics.go" }] }, + }, + ], + reasoning_details: [ + { + type: "reasoning.summary", + summary: "## Reading the file to understand the structure", + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.encrypted", + data: "encrypted_data_here", + id: "rs_12345", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through all reasoning_details + expect(assistantMessage.reasoning_details).toHaveLength(2) + + // Should have tool_calls + expect(assistantMessage.tool_calls).toHaveLength(1) + expect(assistantMessage.tool_calls[0].id).toBe("call_62462410") + expect(assistantMessage.tool_calls[0].function.name).toBe("read_file") + }) + + it("should pass through reasoning_details with only encrypted blocks", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response text" }], + reasoning_details: [ + { + type: "reasoning.encrypted", + data: "encrypted_data", + id: "rs_only_encrypted", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should still pass through reasoning_details + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.encrypted") + }) + + it("should pass through reasoning_details even when only summary blocks exist (no encrypted)", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response text" }], + reasoning_details: [ + { + type: "reasoning.summary", + summary: "Just a summary, no encrypted content", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through reasoning_details preserving the summary block + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + expect(assistantMessage.reasoning_details[0].summary).toBe("Just a summary, no encrypted content") + }) + + it("should handle messages without reasoning_details", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [{ type: "text", text: "Simple response" }], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should not have reasoning or reasoning_details + expect(assistantMessage.reasoning).toBeUndefined() + expect(assistantMessage.reasoning_details).toBeUndefined() + }) + + it("should pass through multiple reasoning_details blocks preserving all fields", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response" }], + reasoning_details: [ + { + type: "reasoning.summary", + summary: "First part of thinking. ", + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.summary", + summary: "Second part of thinking.", + format: "xai-responses-v1", + index: 1, + }, + { + type: "reasoning.encrypted", + data: "encrypted_data", + id: "rs_multi", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through all reasoning_details + expect(assistantMessage.reasoning_details).toHaveLength(3) + expect(assistantMessage.reasoning_details[0].summary).toBe("First part of thinking. ") + expect(assistantMessage.reasoning_details[1].summary).toBe("Second part of thinking.") + expect(assistantMessage.reasoning_details[2].data).toBe("encrypted_data") + }) + }) }) diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index e481864034..de48d27a3f 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -27,12 +27,47 @@ export function convertToOpenAiMessages( ): OpenAI.Chat.ChatCompletionMessageParam[] { const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [] + const mapReasoningDetails = (details: unknown): any[] | undefined => { + if (!Array.isArray(details)) { + return undefined + } + + return details.map((detail: any) => { + // Strip `id` from openai-responses-v1 blocks because OpenAI's Responses API + // requires `store: true` to persist reasoning blocks. Since we manage + // conversation state client-side, we don't use `store: true`, and sending + // back the `id` field causes a 404 error. + if (detail?.format === "openai-responses-v1" && detail?.id) { + const { id, ...rest } = detail + return rest + } + return detail + }) + } + // Use provided normalization function or identity function const normalizeId = options?.normalizeToolCallId ?? ((id: string) => id) for (const anthropicMessage of anthropicMessages) { if (typeof anthropicMessage.content === "string") { - openAiMessages.push({ role: anthropicMessage.role, content: anthropicMessage.content }) + // Some upstream transforms (e.g. [`Task.buildCleanConversationHistory()`](src/core/task/Task.ts:4048)) + // will convert a single text block into a string for compactness. + // If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.), + // we must preserve it here as well. + const messageWithDetails = anthropicMessage as any + const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = { + role: anthropicMessage.role, + content: anthropicMessage.content, + } + + if (anthropicMessage.role === "assistant") { + const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) + if (mapped) { + ;(baseMessage as any).reasoning_details = mapped + } + } + + openAiMessages.push(baseMessage) } else { // image_url.url is base64 encoded image data // ensure it contains the content-type of the image: data:image/png;base64, @@ -178,24 +213,24 @@ export function convertToOpenAiMessages( }, })) - // Check if the message has reasoning_details (used by Gemini 3, etc.) + // Check if the message has reasoning_details (used by Gemini 3, xAI, etc.) const messageWithDetails = anthropicMessage as any // Build message with reasoning_details BEFORE tool_calls to preserve // the order expected by providers like Roo. Property order matters // when sending messages back to some APIs. - const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { reasoning_details?: any[] } = { + const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { + reasoning_details?: any[] + } = { role: "assistant", content, } - // Add reasoning_details first (before tool_calls) to preserve provider-expected order - // Strip the id field from each reasoning detail as it's only used internally for accumulation - if (messageWithDetails.reasoning_details && Array.isArray(messageWithDetails.reasoning_details)) { - baseMessage.reasoning_details = messageWithDetails.reasoning_details.map((detail: any) => { - const { id, ...rest } = detail - return rest - }) + // Pass through reasoning_details to preserve the original shape from the API. + // The `id` field is stripped from openai-responses-v1 blocks (see mapReasoningDetails). + const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) + if (mapped) { + baseMessage.reasoning_details = mapped } // Add tool_calls after reasoning_details diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 48c85a160e..f6eac36a9c 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -822,8 +822,6 @@ export class NativeToolCallParser { default: if (customToolRegistry.has(resolvedName)) { nativeArgs = args as NativeArgsFor - } else { - console.error(`Unhandled tool: ${resolvedName}`) } break diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts new file mode 100644 index 0000000000..e90646fd9a --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -0,0 +1,361 @@ +// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts + +import { describe, it, expect, beforeEach, vi } from "vitest" +import { presentAssistantMessage } from "../presentAssistantMessage" + +// Mock dependencies +vi.mock("../../task/Task") +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), +})) + +// Mock custom tool registry - must be done inline without external variable references +vi.mock("@roo-code/core", () => ({ + customToolRegistry: { + has: vi.fn(), + get: vi.fn(), + }, +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + }, + }, +})) + +import { TelemetryService } from "@roo-code/telemetry" +import { customToolRegistry } from "@roo-code/core" + +describe("presentAssistantMessage - Custom Tool Recording", () => { + let mockTask: any + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks() + + // Create a mock Task with minimal properties needed for testing + mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: false, + didRejectTool: false, + didAlreadyUseTool: false, + diffEnabled: false, + consecutiveMistakeCount: 0, + clineMessages: [], + api: { + getModel: () => ({ id: "test-model", info: {} }), + }, + browserSession: { + closeBrowser: vi.fn().mockResolvedValue(undefined), + }, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: { + check: vi.fn().mockReturnValue({ allowExecution: true }), + }, + providerRef: { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + experiments: { + customTools: true, // Enable by default + }, + }), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + } + + // Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask + mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { + const existingResult = mockTask.userMessageContent.find( + (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + mockTask.userMessageContent.push(toolResult) + return true + }) + }) + + describe("Custom tool usage recording", () => { + it("should record custom tool usage as 'custom_tool' when experiment is enabled", async () => { + const toolCallId = "tool_call_custom_123" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "my_custom_tool", + params: { value: "test" }, + partial: false, + }, + ] + + // Mock customToolRegistry to recognize this as a custom tool + vi.mocked(customToolRegistry.has).mockReturnValue(true) + vi.mocked(customToolRegistry.get).mockReturnValue({ + name: "my_custom_tool", + description: "A custom tool", + execute: vi.fn().mockResolvedValue("Custom tool result"), + }) + + await presentAssistantMessage(mockTask) + + // Should record as "custom_tool", not "my_custom_tool" + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("custom_tool") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith( + mockTask.taskId, + "custom_tool", + "native", + ) + }) + + it("should record custom tool usage as 'custom_tool' in XML protocol", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + // No ID = XML protocol + name: "my_custom_tool", + params: { value: "test" }, + partial: false, + }, + ] + + vi.mocked(customToolRegistry.has).mockReturnValue(true) + vi.mocked(customToolRegistry.get).mockReturnValue({ + name: "my_custom_tool", + description: "A custom tool", + execute: vi.fn().mockResolvedValue("Custom tool result"), + }) + + await presentAssistantMessage(mockTask) + + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("custom_tool") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith( + mockTask.taskId, + "custom_tool", + "xml", + ) + }) + }) + + describe("Custom tool error recording", () => { + it("should record custom tool error as 'custom_tool'", async () => { + const toolCallId = "tool_call_custom_error_123" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "failing_custom_tool", + params: {}, + partial: false, + }, + ] + + // Mock customToolRegistry with a tool that throws an error + vi.mocked(customToolRegistry.has).mockReturnValue(true) + vi.mocked(customToolRegistry.get).mockReturnValue({ + name: "failing_custom_tool", + description: "A failing custom tool", + execute: vi.fn().mockRejectedValue(new Error("Custom tool execution failed")), + }) + + await presentAssistantMessage(mockTask) + + // Should record error as "custom_tool", not "failing_custom_tool" + expect(mockTask.recordToolError).toHaveBeenCalledWith("custom_tool", "Custom tool execution failed") + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + }) + + describe("Regular tool recording", () => { + it("should record regular tool usage with actual tool name", async () => { + const toolCallId = "tool_call_read_file_123" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "read_file", + params: { path: "test.txt" }, + partial: false, + }, + ] + + // read_file is not a custom tool + vi.mocked(customToolRegistry.has).mockReturnValue(false) + + await presentAssistantMessage(mockTask) + + // Should record as "read_file", not "custom_tool" + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("read_file") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith( + mockTask.taskId, + "read_file", + "native", + ) + }) + + it("should record MCP tool usage as 'use_mcp_tool' (not custom_tool)", async () => { + const toolCallId = "tool_call_mcp_123" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "use_mcp_tool", + params: { + server_name: "test-server", + tool_name: "test-tool", + arguments: "{}", + }, + partial: false, + }, + ] + + vi.mocked(customToolRegistry.has).mockReturnValue(false) + + // Mock MCP hub for use_mcp_tool + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + experiments: { + customTools: true, + }, + }), + getMcpHub: () => ({ + findServerNameBySanitizedName: () => "test-server", + executeToolCall: vi.fn().mockResolvedValue({ content: [{ type: "text", text: "result" }] }), + }), + }), + } + + await presentAssistantMessage(mockTask) + + // Should record as "use_mcp_tool", not "custom_tool" + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith( + mockTask.taskId, + "use_mcp_tool", + "native", + ) + }) + }) + + describe("Custom tool experiment gate", () => { + it("should treat custom tool as unknown when experiment is disabled", async () => { + const toolCallId = "tool_call_disabled_123" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "my_custom_tool", + params: {}, + partial: false, + }, + ] + + // Mock provider state with customTools experiment DISABLED + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + experiments: { + customTools: false, // Disabled + }, + }), + }), + } + + // Even if registry recognizes it, experiment gate should prevent execution + vi.mocked(customToolRegistry.has).mockReturnValue(true) + vi.mocked(customToolRegistry.get).mockReturnValue({ + name: "my_custom_tool", + description: "A custom tool", + execute: vi.fn().mockResolvedValue("Should not execute"), + }) + + await presentAssistantMessage(mockTask) + + // Should be treated as unknown tool (not executed) + expect(mockTask.say).toHaveBeenCalledWith("error", "unknownToolError") + expect(mockTask.consecutiveMistakeCount).toBe(1) + + // Custom tool should NOT have been executed + const getMock = vi.mocked(customToolRegistry.get) + if (getMock.mock.results.length > 0) { + const customTool = getMock.mock.results[0].value + if (customTool) { + expect(customTool.execute).not.toHaveBeenCalled() + } + } + }) + + it("should not call customToolRegistry.has() when experiment is disabled", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "tool_call_123", + name: "some_tool", + params: {}, + partial: false, + }, + ] + + // Disable experiment + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + experiments: { + customTools: false, + }, + }), + }), + } + + await presentAssistantMessage(mockTask) + + // When experiment is off, shouldn't even check the registry + // (Code checks stateExperiments?.customTools before calling has()) + expect(customToolRegistry.has).not.toHaveBeenCalled() + }) + }) + + describe("Partial blocks", () => { + it("should not record usage for partial custom tool blocks", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "tool_call_partial_123", + name: "my_custom_tool", + params: { value: "test" }, + partial: true, // Still streaming + }, + ] + + vi.mocked(customToolRegistry.has).mockReturnValue(true) + + await presentAssistantMessage(mockTask) + + // Should not record usage for partial blocks + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + expect(TelemetryService.instance.captureToolUsage).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts index 39d71bc88b..72ee430609 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts @@ -60,6 +60,18 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () => say: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), } + + // Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask + mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { + const existingResult = mockTask.userMessageContent.find( + (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + mockTask.userMessageContent.push(toolResult) + return true + }) }) it("should preserve images in tool_result for native protocol", async () => { diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts index 2c71dc7811..d4ae2764a0 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts @@ -59,6 +59,18 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { say: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), } + + // Add pushToolResultToUserContent method after mockTask is created so 'this' binds correctly + mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { + const existingResult = mockTask.userMessageContent.find( + (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + mockTask.userMessageContent.push(toolResult) + return true + }) }) it("should return error for unknown tool in native protocol", async () => { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index c1876b8cd0..28cb038d3e 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -19,8 +19,7 @@ import { Task } from "../task/Task" import { fetchInstructionsTool } from "../tools/FetchInstructionsTool" import { listFilesTool } from "../tools/ListFilesTool" import { readFileTool } from "../tools/ReadFileTool" -import { getSimpleReadFileToolDescription, simpleReadFileTool } from "../tools/simpleReadFileTool" -import { shouldUseSingleFileRead, TOOL_PROTOCOL } from "@roo-code/types" +import { TOOL_PROTOCOL } from "@roo-code/types" import { writeToFileTool } from "../tools/WriteToFileTool" import { applyDiffTool } from "../tools/MultiApplyDiffTool" import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool" @@ -116,12 +115,12 @@ export async function presentAssistantMessage(cline: Task) { : `MCP tool ${mcpBlock.name} was interrupted and not executed due to user rejecting a previous tool.` if (toolCallId) { - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: errorMessage, is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } break } @@ -131,12 +130,12 @@ export async function presentAssistantMessage(cline: Task) { const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.` if (toolCallId) { - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: errorMessage, is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } break } @@ -168,11 +167,11 @@ export async function presentAssistantMessage(cline: Task) { } if (toolCallId) { - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: resultContent, - } as Anthropic.ToolResultBlockParam) + }) if (imageBlocks.length > 0) { cline.userMessageContent.push(...imageBlocks) @@ -363,18 +362,12 @@ export async function presentAssistantMessage(cline: Task) { case "execute_command": return `[${block.name} for '${block.params.command}']` case "read_file": - // Check if this model should use the simplified description - const modelId = cline.api.getModel().id - if (shouldUseSingleFileRead(modelId)) { - return getSimpleReadFileToolDescription(block.name, block.params) - } else { - // Prefer native typed args when available; fall back to legacy params - // Check if nativeArgs exists (native protocol) - if (block.nativeArgs) { - return readFileTool.getReadFileToolDescription(block.name, block.nativeArgs) - } - return readFileTool.getReadFileToolDescription(block.name, block.params) + // Prefer native typed args when available; fall back to legacy params + // Check if nativeArgs exists (native protocol) + if (block.nativeArgs) { + return readFileTool.getReadFileToolDescription(block.name, block.nativeArgs) } + return readFileTool.getReadFileToolDescription(block.name, block.params) case "fetch_instructions": return `[${block.name} for '${block.params.task}']` case "write_to_file": @@ -453,12 +446,12 @@ export async function presentAssistantMessage(cline: Task) { if (toolCallId) { // Native protocol: MUST send tool_result for every tool_use - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: errorMessage, is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } else { // XML protocol: send as text cline.userMessageContent.push({ @@ -478,12 +471,12 @@ export async function presentAssistantMessage(cline: Task) { if (toolCallId) { // Native protocol: MUST send tool_result for every tool_use - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: errorMessage, is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } else { // XML protocol: send as text cline.userMessageContent.push({ @@ -537,11 +530,11 @@ export async function presentAssistantMessage(cline: Task) { } // Add tool_result with text content only - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: resultContent, - } as Anthropic.ToolResultBlockParam) + }) // Add image blocks separately after tool_result if (imageBlocks.length > 0) { @@ -702,8 +695,11 @@ export async function presentAssistantMessage(cline: Task) { } if (!block.partial) { - cline.recordToolUsage(block.name) - TelemetryService.instance.captureToolUsage(cline.taskId, block.name, toolProtocol) + // Check if this is a custom tool - if so, record as "custom_tool" (like MCP tools) + const isCustomTool = stateExperiments?.customTools && customToolRegistry.has(block.name) + const recordName = isCustomTool ? "custom_tool" : block.name + cline.recordToolUsage(recordName) + TelemetryService.instance.captureToolUsage(cline.taskId, recordName, toolProtocol) } // Validate tool use before execution - ONLY for complete (non-partial) blocks. @@ -739,12 +735,12 @@ export async function presentAssistantMessage(cline: Task) { if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) { // For native protocol, push tool_result directly without setting didAlreadyUseTool - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: typeof errorContent === "string" ? errorContent : "(validation error)", is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } else { // For XML protocol, use the standard pushToolResult pushToolResult(errorContent) @@ -909,29 +905,14 @@ export async function presentAssistantMessage(cline: Task) { }) break case "read_file": - // Check if this model should use the simplified single-file read tool - // Only use simplified tool for XML protocol - native protocol works with standard tool - const modelId = cline.api.getModel().id - if (shouldUseSingleFileRead(modelId) && toolProtocol !== TOOL_PROTOCOL.NATIVE) { - await simpleReadFileTool( - cline, - block, - askApproval, - handleError, - pushToolResult, - removeClosingTag, - toolProtocol, - ) - } else { - // Type assertion is safe here because we're in the "read_file" case - await readFileTool.handle(cline, block as ToolUse<"read_file">, { - askApproval, - handleError, - pushToolResult, - removeClosingTag, - toolProtocol, - }) - } + // Type assertion is safe here because we're in the "read_file" case + await readFileTool.handle(cline, block as ToolUse<"read_file">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) break case "fetch_instructions": await fetchInstructionsTool.handle(cline, block as ToolUse<"fetch_instructions">, { @@ -1113,6 +1094,8 @@ export async function presentAssistantMessage(cline: Task) { cline.consecutiveMistakeCount = 0 } catch (executionError: any) { cline.consecutiveMistakeCount++ + // Record custom tool error with static name + cline.recordToolError("custom_tool", executionError.message) await handleError(`executing custom tool "${block.name}"`, executionError) } @@ -1127,12 +1110,12 @@ export async function presentAssistantMessage(cline: Task) { // Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool // This prevents the stream from being interrupted with "Response interrupted by tool use result" if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) { - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: formatResponse.toolError(errorMessage, toolProtocol), is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } else { pushToolResult(formatResponse.toolError(errorMessage, toolProtocol)) } diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index 1309afb221..ef5af01243 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -334,6 +334,254 @@ describe("getKeepMessagesWithToolBlocks", () => { expect(result.toolUseBlocksToPreserve).toHaveLength(1) expect(result.reasoningBlocksToPreserve).toHaveLength(0) }) + + it("should preserve tool_use when tool_result is in 2nd kept message and tool_use is 2 messages before boundary", () => { + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_second_kept", + name: "read_file", + input: { path: "test.txt" }, + } + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_second_kept", + content: "file contents", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Let me help", ts: 2 }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock], + ts: 3, + }, + { role: "user", content: "Some other message", ts: 4 }, + { role: "assistant", content: "First kept message", ts: 5 }, + { + role: "user", + content: [toolResultBlock, { type: "text" as const, text: "Continue" }], + ts: 6, + }, + { role: "assistant", content: "Third kept message", ts: 7 }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages (ts: 5, 6, 7) + expect(result.keepMessages).toHaveLength(3) + expect(result.keepMessages[0].ts).toBe(5) + expect(result.keepMessages[1].ts).toBe(6) + expect(result.keepMessages[2].ts).toBe(7) + + // Should preserve the tool_use block from message at ts:3 (2 messages before boundary) + expect(result.toolUseBlocksToPreserve).toHaveLength(1) + expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + }) + + it("should preserve tool_use when tool_result is in 3rd kept message and tool_use is at boundary edge", () => { + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_third_kept", + name: "search", + input: { query: "test" }, + } + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_third_kept", + content: "search results", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Start", ts: 1 }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Searching..." }, toolUseBlock], + ts: 2, + }, + { role: "user", content: "First kept message", ts: 3 }, + { role: "assistant", content: "Second kept message", ts: 4 }, + { + role: "user", + content: [toolResultBlock, { type: "text" as const, text: "Done" }], + ts: 5, + }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages (ts: 3, 4, 5) + expect(result.keepMessages).toHaveLength(3) + expect(result.keepMessages[0].ts).toBe(3) + expect(result.keepMessages[1].ts).toBe(4) + expect(result.keepMessages[2].ts).toBe(5) + + // Should preserve the tool_use block from message at ts:2 (at the search boundary edge) + expect(result.toolUseBlocksToPreserve).toHaveLength(1) + expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + }) + + it("should preserve multiple tool_uses when tool_results are in different kept messages", () => { + const toolUseBlock1 = { + type: "tool_use" as const, + id: "toolu_multi_1", + name: "read_file", + input: { path: "file1.txt" }, + } + const toolUseBlock2 = { + type: "tool_use" as const, + id: "toolu_multi_2", + name: "read_file", + input: { path: "file2.txt" }, + } + const toolResultBlock1 = { + type: "tool_result" as const, + tool_use_id: "toolu_multi_1", + content: "contents 1", + } + const toolResultBlock2 = { + type: "tool_result" as const, + tool_use_id: "toolu_multi_2", + content: "contents 2", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Start", ts: 1 }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Reading file 1..." }, toolUseBlock1], + ts: 2, + }, + { role: "user", content: "Some message", ts: 3 }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Reading file 2..." }, toolUseBlock2], + ts: 4, + }, + { + role: "user", + content: [toolResultBlock1, { type: "text" as const, text: "First result" }], + ts: 5, + }, + { + role: "user", + content: [toolResultBlock2, { type: "text" as const, text: "Second result" }], + ts: 6, + }, + { role: "assistant", content: "Got both files", ts: 7 }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages (ts: 5, 6, 7) + expect(result.keepMessages).toHaveLength(3) + + // Should preserve both tool_use blocks + expect(result.toolUseBlocksToPreserve).toHaveLength(2) + expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock1) + expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock2) + }) + + it("should not crash when tool_result references tool_use beyond search boundary", () => { + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_beyond_boundary", + content: "result", + } + + // Tool_use is at ts:1, but with N_MESSAGES_TO_KEEP=3, we only search back 3 messages + // from startIndex-1. StartIndex is 7 (messages.length=10, keepCount=3, startIndex=7). + // So we search from index 6 down to index 4 (7-1 down to 7-3). + // The tool_use at index 0 (ts:1) is beyond the search boundary. + const messages: ApiMessage[] = [ + { + role: "assistant", + content: [ + { type: "text" as const, text: "Way back..." }, + { + type: "tool_use" as const, + id: "toolu_beyond_boundary", + name: "old_tool", + input: {}, + }, + ], + ts: 1, + }, + { role: "user", content: "Message 2", ts: 2 }, + { role: "assistant", content: "Message 3", ts: 3 }, + { role: "user", content: "Message 4", ts: 4 }, + { role: "assistant", content: "Message 5", ts: 5 }, + { role: "user", content: "Message 6", ts: 6 }, + { role: "assistant", content: "Message 7", ts: 7 }, + { + role: "user", + content: [toolResultBlock], + ts: 8, + }, + { role: "assistant", content: "Message 9", ts: 9 }, + { role: "user", content: "Message 10", ts: 10 }, + ] + + // Should not crash + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages + expect(result.keepMessages).toHaveLength(3) + expect(result.keepMessages[0].ts).toBe(8) + expect(result.keepMessages[1].ts).toBe(9) + expect(result.keepMessages[2].ts).toBe(10) + + // Should not preserve the tool_use since it's beyond the search boundary + expect(result.toolUseBlocksToPreserve).toHaveLength(0) + }) + + it("should not duplicate tool_use blocks when same tool_result ID appears multiple times", () => { + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_duplicate", + name: "read_file", + input: { path: "test.txt" }, + } + const toolResultBlock1 = { + type: "tool_result" as const, + tool_use_id: "toolu_duplicate", + content: "result 1", + } + const toolResultBlock2 = { + type: "tool_result" as const, + tool_use_id: "toolu_duplicate", + content: "result 2", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Start", ts: 1 }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Using tool..." }, toolUseBlock], + ts: 2, + }, + { + role: "user", + content: [toolResultBlock1], + ts: 3, + }, + { role: "assistant", content: "Processing", ts: 4 }, + { + role: "user", + content: [toolResultBlock2], // Same tool_use_id as first result + ts: 5, + }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages (ts: 3, 4, 5) + expect(result.keepMessages).toHaveLength(3) + + // Should only preserve the tool_use block once, not twice + expect(result.toolUseBlocksToPreserve).toHaveLength(1) + expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + }) }) describe("getMessagesSinceLastSummary", () => { diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 3238eca707..79bc31ef9f 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -7,6 +7,7 @@ import { t } from "../../i18n" import { ApiHandler } from "../../api" import { ApiMessage } from "../task-persistence/apiMessages" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" +import { findLast } from "../../shared/array" /** * Checks if a message contains tool_result blocks. @@ -30,6 +31,28 @@ function getToolUseBlocks(message: ApiMessage): Anthropic.Messages.ToolUseBlock[ return message.content.filter((block) => block.type === "tool_use") as Anthropic.Messages.ToolUseBlock[] } +/** + * Gets the tool_result blocks from a message. + */ +function getToolResultBlocks(message: ApiMessage): Anthropic.ToolResultBlockParam[] { + if (message.role !== "user" || typeof message.content === "string") { + return [] + } + return message.content.filter((block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result") +} + +/** + * Finds a tool_use block by ID in a message. + */ +function findToolUseBlockById(message: ApiMessage, toolUseId: string): Anthropic.Messages.ToolUseBlock | undefined { + if (message.role !== "assistant" || typeof message.content === "string") { + return undefined + } + return message.content.find( + (block): block is Anthropic.Messages.ToolUseBlock => block.type === "tool_use" && block.id === toolUseId, + ) +} + /** * Gets reasoning blocks from a message's content array. * Task stores reasoning as {type: "reasoning", text: "..."} blocks, @@ -57,11 +80,11 @@ export type KeepMessagesResult = { /** * Extracts tool_use blocks that need to be preserved to match tool_result blocks in keepMessages. - * When the first kept message is a user message with tool_result blocks, - * we need to find the corresponding tool_use blocks from the preceding assistant message. + * Checks ALL kept messages for tool_result blocks and searches backwards through the condensed + * region (bounded by N_MESSAGES_TO_KEEP) to find the matching tool_use blocks by ID. * These tool_use blocks will be appended to the summary message to maintain proper pairing. * - * Also extracts reasoning blocks from the preceding assistant message, which are required + * Also extracts reasoning blocks from messages containing preserved tool_uses, which are required * by DeepSeek and Z.ai for interleaved thinking mode. Without these, the API returns a 400 error * "Missing reasoning_content field in the assistant message". * See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls @@ -78,28 +101,53 @@ export function getKeepMessagesWithToolBlocks(messages: ApiMessage[], keepCount: const startIndex = messages.length - keepCount const keepMessages = messages.slice(startIndex) - // Check if the first kept message is a user message with tool_result blocks - if (keepMessages.length > 0 && hasToolResultBlocks(keepMessages[0])) { - // Look for the preceding assistant message with tool_use blocks - const precedingIndex = startIndex - 1 - if (precedingIndex >= 0) { - const precedingMessage = messages[precedingIndex] - const toolUseBlocks = getToolUseBlocks(precedingMessage) - if (toolUseBlocks.length > 0) { - // Also extract reasoning blocks for DeepSeek/Z.ai interleaved thinking - // Task stores reasoning as {type: "reasoning", text: "..."} content blocks - const reasoningBlocks = getReasoningBlocks(precedingMessage) - // Return the tool_use blocks and reasoning blocks to be merged into the summary message - return { - keepMessages, - toolUseBlocksToPreserve: toolUseBlocks, - reasoningBlocksToPreserve: reasoningBlocks, - } + const toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] = [] + const reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[] = [] + const preservedToolUseIds = new Set() + + // Check ALL kept messages for tool_result blocks + for (const keepMsg of keepMessages) { + if (!hasToolResultBlocks(keepMsg)) { + continue + } + + const toolResults = getToolResultBlocks(keepMsg) + + for (const toolResult of toolResults) { + const toolUseId = toolResult.tool_use_id + + // Skip if we've already found this tool_use + if (preservedToolUseIds.has(toolUseId)) { + continue + } + + // Search backwards through the condensed region (bounded) + const searchStart = startIndex - 1 + const searchEnd = Math.max(0, startIndex - N_MESSAGES_TO_KEEP) + const messagesToSearch = messages.slice(searchEnd, searchStart + 1) + + // Find the message containing this tool_use + const messageWithToolUse = findLast(messagesToSearch, (msg) => { + return findToolUseBlockById(msg, toolUseId) !== undefined + }) + + if (messageWithToolUse) { + const toolUse = findToolUseBlockById(messageWithToolUse, toolUseId)! + toolUseBlocksToPreserve.push(toolUse) + preservedToolUseIds.add(toolUseId) + + // Also preserve reasoning blocks from that message + const reasoning = getReasoningBlocks(messageWithToolUse) + reasoningBlocksToPreserve.push(...reasoning) } } } - return { keepMessages, toolUseBlocksToPreserve: [], reasoningBlocksToPreserve: [] } + return { + keepMessages, + toolUseBlocksToPreserve, + reasoningBlocksToPreserve, + } } export const N_MESSAGES_TO_KEEP = 3 diff --git a/src/core/mentions/__tests__/index.spec.ts b/src/core/mentions/__tests__/index.spec.ts index 2cb24b4502..8f229c28b8 100644 --- a/src/core/mentions/__tests__/index.spec.ts +++ b/src/core/mentions/__tests__/index.spec.ts @@ -40,7 +40,7 @@ describe("parseMentions - URL error handling", () => { expect(consoleErrorSpy).toHaveBeenCalledWith("Error fetching URL https://example.com:", timeoutError) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: Navigation timeout of 30000 ms exceeded") + expect(result.text).toContain("Error fetching content: Navigation timeout of 30000 ms exceeded") }) it("should handle DNS resolution errors", async () => { @@ -50,7 +50,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://nonexistent.example", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: net::ERR_NAME_NOT_RESOLVED") + expect(result.text).toContain("Error fetching content: net::ERR_NAME_NOT_RESOLVED") }) it("should handle network disconnection errors", async () => { @@ -60,7 +60,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: net::ERR_INTERNET_DISCONNECTED") + expect(result.text).toContain("Error fetching content: net::ERR_INTERNET_DISCONNECTED") }) it("should handle 403 Forbidden errors", async () => { @@ -70,7 +70,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: 403 Forbidden") + expect(result.text).toContain("Error fetching content: 403 Forbidden") }) it("should handle 404 Not Found errors", async () => { @@ -80,7 +80,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com/missing", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: 404 Not Found") + expect(result.text).toContain("Error fetching content: 404 Not Found") }) it("should handle generic errors with fallback message", async () => { @@ -90,7 +90,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: Some unexpected error") + expect(result.text).toContain("Error fetching content: Some unexpected error") }) it("should handle non-Error objects thrown", async () => { @@ -100,7 +100,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content:") + expect(result.text).toContain("Error fetching content:") }) it("should handle browser launch errors correctly", async () => { @@ -112,7 +112,7 @@ describe("parseMentions - URL error handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( "Error fetching content for https://example.com: Failed to launch browser", ) - expect(result).toContain("Error fetching content: Failed to launch browser") + expect(result.text).toContain("Error fetching content: Failed to launch browser") // Should not attempt to fetch URL if browser launch failed expect(mockUrlContentFetcher.urlToMarkdown).not.toHaveBeenCalled() }) @@ -126,7 +126,7 @@ describe("parseMentions - URL error handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( "Error fetching content for https://example.com: String error", ) - expect(result).toContain("Error fetching content: String error") + expect(result.text).toContain("Error fetching content: String error") }) it("should successfully fetch URL content when no errors occur", async () => { @@ -135,9 +135,9 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() - expect(result).toContain('') - expect(result).toContain("# Example Content\n\nThis is the content.") - expect(result).toContain("") + expect(result.text).toContain('') + expect(result.text).toContain("# Example Content\n\nThis is the content.") + expect(result.text).toContain("") }) it("should handle multiple URLs with mixed success and failure", async () => { @@ -151,9 +151,9 @@ describe("parseMentions - URL error handling", () => { mockUrlContentFetcher, ) - expect(result).toContain('') - expect(result).toContain("# First Site") - expect(result).toContain('') - expect(result).toContain("Error fetching content: timeout") + expect(result.text).toContain('') + expect(result.text).toContain("# First Site") + expect(result.text).toContain('') + expect(result.text).toContain("Error fetching content: timeout") }) }) diff --git a/src/core/mentions/__tests__/processUserContentMentions.spec.ts b/src/core/mentions/__tests__/processUserContentMentions.spec.ts index 13c225042d..ec2e08f92a 100644 --- a/src/core/mentions/__tests__/processUserContentMentions.spec.ts +++ b/src/core/mentions/__tests__/processUserContentMentions.spec.ts @@ -22,8 +22,11 @@ describe("processUserContentMentions", () => { mockFileContextTracker = {} as FileContextTracker mockRooIgnoreController = {} - // Default mock implementation - vi.mocked(parseMentions).mockImplementation(async (text) => `parsed: ${text}`) + // Default mock implementation - returns ParseMentionsResult object + vi.mocked(parseMentions).mockImplementation(async (text) => ({ + text: `parsed: ${text}`, + mode: undefined, + })) }) describe("maxReadFileLine parameter", () => { @@ -134,10 +137,11 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).toHaveBeenCalled() - expect(result[0]).toEqual({ + expect(result.content[0]).toEqual({ type: "text", text: "parsed: Do something", }) + expect(result.mode).toBeUndefined() }) it("should process text blocks with tags", async () => { @@ -156,10 +160,11 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).toHaveBeenCalled() - expect(result[0]).toEqual({ + expect(result.content[0]).toEqual({ type: "text", text: "parsed: Fix this issue", }) + expect(result.mode).toBeUndefined() }) it("should not process text blocks without task or feedback tags", async () => { @@ -178,7 +183,8 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).not.toHaveBeenCalled() - expect(result[0]).toEqual(userContent[0]) + expect(result.content[0]).toEqual(userContent[0]) + expect(result.mode).toBeUndefined() }) it("should process tool_result blocks with string content", async () => { @@ -198,11 +204,12 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).toHaveBeenCalled() - expect(result[0]).toEqual({ + expect(result.content[0]).toEqual({ type: "tool_result", tool_use_id: "123", content: "parsed: Tool feedback", }) + expect(result.mode).toBeUndefined() }) it("should process tool_result blocks with array content", async () => { @@ -231,7 +238,7 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).toHaveBeenCalledTimes(1) - expect(result[0]).toEqual({ + expect(result.content[0]).toEqual({ type: "tool_result", tool_use_id: "123", content: [ @@ -245,6 +252,7 @@ describe("processUserContentMentions", () => { }, ], }) + expect(result.mode).toBeUndefined() }) it("should handle mixed content types", async () => { @@ -277,17 +285,18 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).toHaveBeenCalledTimes(2) - expect(result).toHaveLength(3) - expect(result[0]).toEqual({ + expect(result.content).toHaveLength(3) + expect(result.content[0]).toEqual({ type: "text", text: "parsed: First task", }) - expect(result[1]).toEqual(userContent[1]) // Image block unchanged - expect(result[2]).toEqual({ + expect(result.content[1]).toEqual(userContent[1]) // Image block unchanged + expect(result.content[2]).toEqual({ type: "tool_result", tool_use_id: "456", content: "parsed: Feedback", }) + expect(result.mode).toBeUndefined() }) }) diff --git a/src/core/mentions/__tests__/resolveImageMentions.spec.ts b/src/core/mentions/__tests__/resolveImageMentions.spec.ts new file mode 100644 index 0000000000..747c778819 --- /dev/null +++ b/src/core/mentions/__tests__/resolveImageMentions.spec.ts @@ -0,0 +1,193 @@ +import * as path from "path" + +import { resolveImageMentions } from "../resolveImageMentions" + +vi.mock("../../tools/helpers/imageHelpers", () => ({ + isSupportedImageFormat: vi.fn((ext: string) => + [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico", ".tiff", ".tif", ".avif"].includes( + ext.toLowerCase(), + ), + ), + readImageAsDataUrlWithBuffer: vi.fn(), + validateImageForProcessing: vi.fn(), + ImageMemoryTracker: vi.fn().mockImplementation(() => ({ + getTotalMemoryUsed: vi.fn().mockReturnValue(0), + addMemoryUsage: vi.fn(), + })), + DEFAULT_MAX_IMAGE_FILE_SIZE_MB: 5, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB: 20, +})) + +import { validateImageForProcessing, readImageAsDataUrlWithBuffer } from "../../tools/helpers/imageHelpers" + +const mockReadImageAsDataUrl = vi.mocked(readImageAsDataUrlWithBuffer) +const mockValidateImage = vi.mocked(validateImageForProcessing) + +describe("resolveImageMentions", () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: validation passes + mockValidateImage.mockResolvedValue({ isValid: true, sizeInMB: 0.1 }) + }) + + it("should append a data URL when a local png mention is present", async () => { + const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}` + mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") }) + + const result = await resolveImageMentions({ + text: "Please look at @/assets/cat.png", + images: [], + cwd: "/workspace", + }) + + expect(mockValidateImage).toHaveBeenCalled() + expect(mockReadImageAsDataUrl).toHaveBeenCalledWith(path.resolve("/workspace", "assets/cat.png")) + expect(result.text).toBe("Please look at @/assets/cat.png") + expect(result.images).toEqual([dataUrl]) + }) + + it("should support gif images (matching read_file)", async () => { + const dataUrl = `data:image/gif;base64,${Buffer.from("gif-bytes").toString("base64")}` + mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("gif-bytes") }) + + const result = await resolveImageMentions({ + text: "See @/animation.gif", + images: [], + cwd: "/workspace", + }) + + expect(result.images).toEqual([dataUrl]) + }) + + it("should support svg images (matching read_file)", async () => { + const dataUrl = `data:image/svg+xml;base64,${Buffer.from("svg-bytes").toString("base64")}` + mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("svg-bytes") }) + + const result = await resolveImageMentions({ + text: "See @/icon.svg", + images: [], + cwd: "/workspace", + }) + + expect(result.images).toEqual([dataUrl]) + }) + + it("should ignore non-image mentions", async () => { + const result = await resolveImageMentions({ + text: "See @/src/index.ts", + images: [], + cwd: "/workspace", + }) + + expect(mockReadImageAsDataUrl).not.toHaveBeenCalled() + expect(result.images).toEqual([]) + }) + + it("should skip unreadable files (fail-soft)", async () => { + mockReadImageAsDataUrl.mockRejectedValue(new Error("ENOENT")) + + const result = await resolveImageMentions({ + text: "See @/missing.webp", + images: [], + cwd: "/workspace", + }) + + expect(result.images).toEqual([]) + }) + + it("should respect rooIgnoreController", async () => { + const dataUrl = `data:image/jpeg;base64,${Buffer.from("jpg-bytes").toString("base64")}` + mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("jpg-bytes") }) + const rooIgnoreController = { + validateAccess: vi.fn().mockReturnValue(false), + } + + const result = await resolveImageMentions({ + text: "See @/secret.jpg", + images: [], + cwd: "/workspace", + rooIgnoreController, + }) + + expect(rooIgnoreController.validateAccess).toHaveBeenCalledWith("secret.jpg") + expect(mockReadImageAsDataUrl).not.toHaveBeenCalled() + expect(result.images).toEqual([]) + }) + + it("should dedupe when mention repeats", async () => { + const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}` + mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") }) + + const result = await resolveImageMentions({ + text: "@/a.png and again @/a.png", + images: [], + cwd: "/workspace", + }) + + expect(result.images).toHaveLength(1) + }) + + it("should skip images when supportsImages is false", async () => { + const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}` + mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") }) + + const result = await resolveImageMentions({ + text: "See @/cat.png", + images: [], + cwd: "/workspace", + supportsImages: false, + }) + + expect(mockReadImageAsDataUrl).not.toHaveBeenCalled() + expect(result.images).toEqual([]) + }) + + it("should skip images that exceed size limits", async () => { + mockValidateImage.mockResolvedValue({ + isValid: false, + reason: "size_limit", + notice: "Image too large", + }) + + const result = await resolveImageMentions({ + text: "See @/huge.png", + images: [], + cwd: "/workspace", + }) + + expect(mockValidateImage).toHaveBeenCalled() + expect(mockReadImageAsDataUrl).not.toHaveBeenCalled() + expect(result.images).toEqual([]) + }) + + it("should skip images that would exceed memory limit", async () => { + mockValidateImage.mockResolvedValue({ + isValid: false, + reason: "memory_limit", + notice: "Would exceed memory limit", + }) + + const result = await resolveImageMentions({ + text: "See @/large.png", + images: [], + cwd: "/workspace", + }) + + expect(result.images).toEqual([]) + }) + + it("should pass custom size limits to validation", async () => { + const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}` + mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") }) + + await resolveImageMentions({ + text: "See @/cat.png", + images: [], + cwd: "/workspace", + maxImageFileSize: 10, + maxTotalImageSize: 50, + }) + + expect(mockValidateImage).toHaveBeenCalledWith(expect.any(String), true, 10, 50, 0) + }) +}) diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index f038b5b783..2bbbf9ed0d 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -71,6 +71,11 @@ export async function openMention(cwd: string, mention?: string): Promise } } +export interface ParseMentionsResult { + text: string + mode?: string // Mode from the first slash command that has one +} + export async function parseMentions( text: string, cwd: string, @@ -81,9 +86,10 @@ export async function parseMentions( includeDiagnosticMessages: boolean = true, maxDiagnosticMessages: number = 50, maxReadFileLine?: number, -): Promise { +): Promise { const mentions: Set = new Set() const validCommands: Map = new Map() + let commandMode: string | undefined // Track mode from the first slash command that has one // First pass: check which command mentions exist and cache the results const commandMatches = Array.from(text.matchAll(commandRegexGlobal)) @@ -101,10 +107,14 @@ export async function parseMentions( }), ) - // Store valid commands for later use + // Store valid commands for later use and capture the first mode found for (const { commandName, command } of commandExistenceChecks) { if (command) { validCommands.set(commandName, command) + // Capture the mode from the first command that has one + if (!commandMode && command.mode) { + commandMode = command.mode + } } } @@ -257,7 +267,7 @@ export async function parseMentions( } } - return parsedText + return { text: parsedText, mode: commandMode } } async function getFileOrFolderContent( @@ -274,7 +284,13 @@ async function getFileOrFolderContent( const stats = await fs.stat(absPath) if (stats.isFile()) { - if (rooIgnoreController && !rooIgnoreController.validateAccess(absPath)) { + // Avoid trying to include image binary content as text context. + // Image mentions are handled separately via image attachment flow. + const isBinary = await isBinaryFile(absPath).catch(() => false) + if (isBinary) { + return `(Binary file ${mentionPath} omitted)` + } + if (rooIgnoreController && !rooIgnoreController.validateAccess(unescapedPath)) { return `(File ${mentionPath} is ignored by .rooignore)` } try { @@ -410,3 +426,4 @@ export async function getLatestTerminalOutput(): Promise { // Export processUserContentMentions from its own file export { processUserContentMentions } from "./processUserContentMentions" +export type { ProcessUserContentMentionsResult } from "./processUserContentMentions" diff --git a/src/core/mentions/processUserContentMentions.ts b/src/core/mentions/processUserContentMentions.ts index 4bdb422d48..5ea78f4dc3 100644 --- a/src/core/mentions/processUserContentMentions.ts +++ b/src/core/mentions/processUserContentMentions.ts @@ -1,8 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { parseMentions } from "./index" +import { parseMentions, ParseMentionsResult } from "./index" import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" import { FileContextTracker } from "../context-tracking/FileContextTracker" +export interface ProcessUserContentMentionsResult { + content: Anthropic.Messages.ContentBlockParam[] + mode?: string // Mode from the first slash command that has one +} + /** * Process mentions in user content, specifically within task and feedback tags */ @@ -26,7 +31,10 @@ export async function processUserContentMentions({ includeDiagnosticMessages?: boolean maxDiagnosticMessages?: number maxReadFileLine?: number -}) { +}): Promise { + // Track the first mode found from slash commands + let commandMode: string | undefined + // Process userContent array, which contains various block types: // TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam. // We need to apply parseMentions() to: @@ -37,7 +45,7 @@ export async function processUserContentMentions({ // (see askFollowupQuestion), we place all user generated content in // these tags so they can effectively be used as markers for when we // should parse mentions). - return Promise.all( + const content = await Promise.all( userContent.map(async (block) => { const shouldProcessMentions = (text: string) => text.includes("") || @@ -47,10 +55,33 @@ export async function processUserContentMentions({ if (block.type === "text") { if (shouldProcessMentions(block.text)) { + const result = await parseMentions( + block.text, + cwd, + urlContentFetcher, + fileContextTracker, + rooIgnoreController, + showRooIgnoredFiles, + includeDiagnosticMessages, + maxDiagnosticMessages, + maxReadFileLine, + ) + // Capture the first mode found + if (!commandMode && result.mode) { + commandMode = result.mode + } return { ...block, - text: await parseMentions( - block.text, + text: result.text, + } + } + + return block + } else if (block.type === "tool_result") { + if (typeof block.content === "string") { + if (shouldProcessMentions(block.content)) { + const result = await parseMentions( + block.content, cwd, urlContentFetcher, fileContextTracker, @@ -59,27 +90,14 @@ export async function processUserContentMentions({ includeDiagnosticMessages, maxDiagnosticMessages, maxReadFileLine, - ), - } - } - - return block - } else if (block.type === "tool_result") { - if (typeof block.content === "string") { - if (shouldProcessMentions(block.content)) { + ) + // Capture the first mode found + if (!commandMode && result.mode) { + commandMode = result.mode + } return { ...block, - content: await parseMentions( - block.content, - cwd, - urlContentFetcher, - fileContextTracker, - rooIgnoreController, - showRooIgnoredFiles, - includeDiagnosticMessages, - maxDiagnosticMessages, - maxReadFileLine, - ), + content: result.text, } } @@ -88,19 +106,24 @@ export async function processUserContentMentions({ const parsedContent = await Promise.all( block.content.map(async (contentBlock) => { if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) { + const result = await parseMentions( + contentBlock.text, + cwd, + urlContentFetcher, + fileContextTracker, + rooIgnoreController, + showRooIgnoredFiles, + includeDiagnosticMessages, + maxDiagnosticMessages, + maxReadFileLine, + ) + // Capture the first mode found + if (!commandMode && result.mode) { + commandMode = result.mode + } return { ...contentBlock, - text: await parseMentions( - contentBlock.text, - cwd, - urlContentFetcher, - fileContextTracker, - rooIgnoreController, - showRooIgnoredFiles, - includeDiagnosticMessages, - maxDiagnosticMessages, - maxReadFileLine, - ), + text: result.text, } } @@ -117,4 +140,6 @@ export async function processUserContentMentions({ return block }), ) + + return { content, mode: commandMode } } diff --git a/src/core/mentions/resolveImageMentions.ts b/src/core/mentions/resolveImageMentions.ts new file mode 100644 index 0000000000..0a0344348f --- /dev/null +++ b/src/core/mentions/resolveImageMentions.ts @@ -0,0 +1,145 @@ +import * as path from "path" + +import { mentionRegexGlobal, unescapeSpaces } from "../../shared/context-mentions" +import { + isSupportedImageFormat, + readImageAsDataUrlWithBuffer, + validateImageForProcessing, + ImageMemoryTracker, + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, +} from "../tools/helpers/imageHelpers" + +const MAX_IMAGES_PER_MESSAGE = 20 + +export interface ResolveImageMentionsOptions { + text: string + images?: string[] + cwd: string + rooIgnoreController?: { validateAccess: (filePath: string) => boolean } + /** Whether the current model supports images. Defaults to true. */ + supportsImages?: boolean + /** Maximum size per image file in MB. Defaults to 5MB. */ + maxImageFileSize?: number + /** Maximum total size of all images in MB. Defaults to 20MB. */ + maxTotalImageSize?: number +} + +export interface ResolveImageMentionsResult { + text: string + images: string[] +} + +function isPathWithinCwd(absPath: string, cwd: string): boolean { + const rel = path.relative(cwd, absPath) + return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel) +} + +function dedupePreserveOrder(values: string[]): string[] { + const seen = new Set() + const result: string[] = [] + for (const v of values) { + if (seen.has(v)) continue + seen.add(v) + result.push(v) + } + return result +} + +/** + * Resolves local image file mentions like `@/path/to/image.png` found in `text` into `data:image/...;base64,...` + * and appends them to the outgoing `images` array. + * + * Behavior matches the read_file tool: + * - Supports the same image formats: png, jpg, jpeg, gif, webp, svg, bmp, ico, tiff, avif + * - Respects per-file size limits (default 5MB) + * - Respects total memory limits (default 20MB) + * - Skips images if model doesn't support them + * - Respects `.rooignore` via `rooIgnoreController.validateAccess` when provided + */ +export async function resolveImageMentions({ + text, + images, + cwd, + rooIgnoreController, + supportsImages = true, + maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, +}: ResolveImageMentionsOptions): Promise { + const existingImages = Array.isArray(images) ? images : [] + if (existingImages.length >= MAX_IMAGES_PER_MESSAGE) { + return { text, images: existingImages.slice(0, MAX_IMAGES_PER_MESSAGE) } + } + + // If model doesn't support images, skip image processing entirely + if (!supportsImages) { + return { text, images: existingImages } + } + + const mentions = Array.from(text.matchAll(mentionRegexGlobal)) + .map((m) => m[1]) + .filter(Boolean) + if (mentions.length === 0) { + return { text, images: existingImages } + } + + const imageMentions = mentions.filter((mention) => { + if (!mention.startsWith("/")) return false + const relPath = unescapeSpaces(mention.slice(1)) + const ext = path.extname(relPath).toLowerCase() + return isSupportedImageFormat(ext) + }) + + if (imageMentions.length === 0) { + return { text, images: existingImages } + } + + const imageMemoryTracker = new ImageMemoryTracker() + const newImages: string[] = [] + + for (const mention of imageMentions) { + if (existingImages.length + newImages.length >= MAX_IMAGES_PER_MESSAGE) { + break + } + + const relPath = unescapeSpaces(mention.slice(1)) + const absPath = path.resolve(cwd, relPath) + if (!isPathWithinCwd(absPath, cwd)) { + continue + } + + if (rooIgnoreController && !rooIgnoreController.validateAccess(relPath)) { + continue + } + + // Validate image size limits (matches read_file behavior) + try { + const validationResult = await validateImageForProcessing( + absPath, + supportsImages, + maxImageFileSize, + maxTotalImageSize, + imageMemoryTracker.getTotalMemoryUsed(), + ) + + if (!validationResult.isValid) { + // Skip this image due to size/memory limits, but continue processing others + continue + } + + const { dataUrl } = await readImageAsDataUrlWithBuffer(absPath) + newImages.push(dataUrl) + + // Track memory usage + if (validationResult.sizeInMB) { + imageMemoryTracker.addMemoryUsage(validationResult.sizeInMB) + } + } catch { + // Fail-soft: skip unreadable/missing files. + continue + } + } + + const merged = dedupePreserveOrder([...existingImages, ...newImages]).slice(0, MAX_IMAGES_PER_MESSAGE) + return { text, images: merged } +} diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index d8a002d8f5..011b279698 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -1,7 +1,8 @@ import { addCustomInstructions } from "../sections/custom-instructions" import { getCapabilitiesSection } from "../sections/capabilities" -import { getRulesSection } from "../sections/rules" +import { getRulesSection, getCommandChainOperator } from "../sections/rules" import { McpHub } from "../../../services/mcp/McpHub" +import * as shellUtils from "../../../utils/shell" describe("addCustomInstructions", () => { it("adds vscode language to custom instructions", async () => { @@ -114,3 +115,117 @@ describe("getRulesSection", () => { expect(result).not.toContain("Never reveal the vendor or company") }) }) + +describe("getCommandChainOperator", () => { + it("returns && for bash shell", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash") + expect(getCommandChainOperator()).toBe("&&") + }) + + it("returns && for zsh shell", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh") + expect(getCommandChainOperator()).toBe("&&") + }) + + it("returns ; for PowerShell", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ) + expect(getCommandChainOperator()).toBe(";") + }) + + it("returns ; for PowerShell Core (pwsh)", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Program Files\\PowerShell\\7\\pwsh.exe") + expect(getCommandChainOperator()).toBe(";") + }) + + it("returns && for cmd.exe", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe") + expect(getCommandChainOperator()).toBe("&&") + }) + + it("returns && for Git Bash on Windows", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Program Files\\Git\\bin\\bash.exe") + expect(getCommandChainOperator()).toBe("&&") + }) + + it("returns && for WSL bash", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash") + expect(getCommandChainOperator()).toBe("&&") + }) +}) + +describe("getRulesSection shell-aware command chaining", () => { + const cwd = "/test/path" + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("uses && for Unix shells in command chaining example", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash") + const result = getRulesSection(cwd) + + expect(result).toContain("cd (path to project) && (command") + expect(result).not.toContain("cd (path to project) ; (command") + expect(result).not.toContain("cd (path to project) & (command") + }) + + it("uses ; for PowerShell in command chaining example", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ) + const result = getRulesSection(cwd) + + expect(result).toContain("cd (path to project) ; (command") + expect(result).toContain("Note: Using `;` for PowerShell command chaining") + }) + + it("uses && for cmd.exe in command chaining example", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe") + const result = getRulesSection(cwd) + + expect(result).toContain("cd (path to project) && (command") + expect(result).toContain("Note: Using `&&` for cmd.exe command chaining") + }) + + it("includes Unix utility guidance for PowerShell", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ) + const result = getRulesSection(cwd) + + expect(result).toContain("IMPORTANT: When using PowerShell, avoid Unix-specific utilities") + expect(result).toContain("`sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`") + expect(result).toContain("`Select-String` for grep") + expect(result).toContain("`Get-Content` for cat") + expect(result).toContain("PowerShell's `-replace` operator") + }) + + it("includes Unix utility guidance for cmd.exe", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe") + const result = getRulesSection(cwd) + + expect(result).toContain("IMPORTANT: When using cmd.exe, avoid Unix-specific utilities") + expect(result).toContain("`sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`") + expect(result).toContain("`type` for cat") + expect(result).toContain("`del` for rm") + expect(result).toContain("`find`/`findstr` for grep") + }) + + it("does not include Unix utility guidance for Unix shells", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash") + const result = getRulesSection(cwd) + + expect(result).not.toContain("IMPORTANT: When using PowerShell") + expect(result).not.toContain("IMPORTANT: When using cmd.exe") + expect(result).not.toContain("`Select-String` for grep") + }) + + it("does not include note for Unix shells", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh") + const result = getRulesSection(cwd) + + expect(result).not.toContain("Note: Using") + }) +}) diff --git a/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts index 7e13096a9d..7f4821f696 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts @@ -1,15 +1,27 @@ import * as path from "path" // Use vi.hoisted to ensure mocks are available during hoisting -const { mockHomedir, mockStat, mockReadFile, mockReaddir, mockGetRooDirectoriesForCwd, mockGetGlobalRooDirectory } = - vi.hoisted(() => ({ - mockHomedir: vi.fn(), - mockStat: vi.fn(), - mockReadFile: vi.fn(), - mockReaddir: vi.fn(), - mockGetRooDirectoriesForCwd: vi.fn(), - mockGetGlobalRooDirectory: vi.fn(), - })) +const { + mockHomedir, + mockStat, + mockReadFile, + mockReaddir, + mockLstat, + mockGetRooDirectoriesForCwd, + mockGetAllRooDirectoriesForCwd, + mockGetAgentsDirectoriesForCwd, + mockGetGlobalRooDirectory, +} = vi.hoisted(() => ({ + mockHomedir: vi.fn(), + mockStat: vi.fn(), + mockReadFile: vi.fn(), + mockReaddir: vi.fn(), + mockLstat: vi.fn(), + mockGetRooDirectoriesForCwd: vi.fn(), + mockGetAllRooDirectoriesForCwd: vi.fn(), + mockGetAgentsDirectoriesForCwd: vi.fn(), + mockGetGlobalRooDirectory: vi.fn(), +})) // Mock os module vi.mock("os", () => ({ @@ -25,12 +37,15 @@ vi.mock("fs/promises", () => ({ stat: mockStat, readFile: mockReadFile, readdir: mockReaddir, + lstat: mockLstat, }, })) // Mock the roo-config service vi.mock("../../../../services/roo-config", () => ({ getRooDirectoriesForCwd: mockGetRooDirectoriesForCwd, + getAllRooDirectoriesForCwd: mockGetAllRooDirectoriesForCwd, + getAgentsDirectoriesForCwd: mockGetAgentsDirectoriesForCwd, getGlobalRooDirectory: mockGetGlobalRooDirectory, })) @@ -46,7 +61,13 @@ describe("custom-instructions global .roo support", () => { vi.clearAllMocks() mockHomedir.mockReturnValue(mockHomeDir) mockGetRooDirectoriesForCwd.mockReturnValue([globalRooDir, projectRooDir]) + // getAllRooDirectoriesForCwd is now async and returns the same directories by default + mockGetAllRooDirectoriesForCwd.mockResolvedValue([globalRooDir, projectRooDir]) + // getAgentsDirectoriesForCwd returns parent directories (without .roo) + mockGetAgentsDirectoriesForCwd.mockResolvedValue([mockCwd]) mockGetGlobalRooDirectory.mockReturnValue(globalRooDir) + // Default lstat to reject (file not found) + mockLstat.mockRejectedValue(new Error("ENOENT")) }) afterEach(() => { @@ -65,7 +86,11 @@ describe("custom-instructions global .roo support", () => { // Mock directory reading for global rules mockReaddir.mockResolvedValueOnce([ - { name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any, + { + name: "rules.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, ]) // Mock file reading for the rules.md file @@ -87,7 +112,11 @@ describe("custom-instructions global .roo support", () => { // Mock directory reading for project rules mockReaddir.mockResolvedValueOnce([ - { name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any, + { + name: "rules.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, ]) // Mock file reading @@ -112,8 +141,20 @@ describe("custom-instructions global .roo support", () => { // Mock directory reading mockReaddir - .mockResolvedValueOnce([{ name: "global.md", isFile: () => true, isSymbolicLink: () => false } as any]) - .mockResolvedValueOnce([{ name: "project.md", isFile: () => true, isSymbolicLink: () => false } as any]) + .mockResolvedValueOnce([ + { + name: "global.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, + ]) + .mockResolvedValueOnce([ + { + name: "project.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, + ]) // Mock file reading mockReadFile.mockResolvedValueOnce("global rule content").mockResolvedValueOnce("project rule content") @@ -182,10 +223,18 @@ describe("custom-instructions global .roo support", () => { // Mock directory reading for mode-specific rules mockReaddir .mockResolvedValueOnce([ - { name: "global-mode.md", isFile: () => true, isSymbolicLink: () => false } as any, + { + name: "global-mode.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, ]) .mockResolvedValueOnce([ - { name: "project-mode.md", isFile: () => true, isSymbolicLink: () => false } as any, + { + name: "project-mode.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, ]) // Mock file reading for mode-specific rules diff --git a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts index 49904465ab..260cb22103 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts @@ -36,7 +36,16 @@ vi.mock("path", async () => ({ .map((arg) => arg.toString().replace(/[/\\]+/g, separator)) return cleanArgs.join(separator) }), - relative: vi.fn().mockImplementation((from, to) => to), + relative: vi.fn().mockImplementation((from, to) => { + // Simple relative path computation for test scenarios + const separator = process.platform === "win32" ? "\\" : "/" + const normalizedFrom = from.replace(/[/\\]+$/, "") // Remove trailing slashes + const normalizedTo = to.replace(/[/\\]+/g, separator) + if (normalizedTo.startsWith(normalizedFrom + separator)) { + return normalizedTo.slice(normalizedFrom.length + 1) + } + return to + }), dirname: vi.fn().mockImplementation((path) => { const separator = process.platform === "win32" ? "\\" : "/" const parts = path.split(/[/\\]/) @@ -200,16 +209,16 @@ describe("loadRuleFiles", () => { }) const result = await loadRuleFiles("/fake/path") - const expectedPath1 = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt" - const expectedPath2 = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file2.txt" : "/fake/path/.roo/rules/file2.txt" - expect(result).toContain(`# Rules from ${expectedPath1}:`) + // Paths in output should be relative to cwd + const expectedRelativePath1 = process.platform === "win32" ? ".roo\\rules\\file1.txt" : ".roo/rules/file1.txt" + const expectedRelativePath2 = process.platform === "win32" ? ".roo\\rules\\file2.txt" : ".roo/rules/file2.txt" + expect(result).toContain(`# Rules from ${expectedRelativePath1}:`) expect(result).toContain("content of file1") - expect(result).toContain(`# Rules from ${expectedPath2}:`) + expect(result).toContain(`# Rules from ${expectedRelativePath2}:`) expect(result).toContain("content of file2") // We expect both checks because our new implementation checks the files again for validation + // These are the absolute paths used internally const expectedRulesDir = process.platform === "win32" ? "\\fake\\path\\.roo\\rules" : "/fake/path/.roo/rules" const expectedFile1Path = process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt" @@ -436,28 +445,25 @@ describe("loadRuleFiles", () => { const result = await loadRuleFiles("/fake/path") - // Check root file content - const expectedRootPath = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\root.txt" : "/fake/path/.roo/rules/root.txt" - const expectedNested1Path = + // Check root file content - paths in output should be relative + const expectedRelativeRootPath = process.platform === "win32" ? ".roo\\rules\\root.txt" : ".roo/rules/root.txt" + const expectedRelativeNested1Path = + process.platform === "win32" ? ".roo\\rules\\subdir\\nested1.txt" : ".roo/rules/subdir/nested1.txt" + const expectedRelativeNested2Path = process.platform === "win32" - ? "\\fake\\path\\.roo\\rules\\subdir\\nested1.txt" - : "/fake/path/.roo/rules/subdir/nested1.txt" - const expectedNested2Path = - process.platform === "win32" - ? "\\fake\\path\\.roo\\rules\\subdir\\subdir2\\nested2.txt" - : "/fake/path/.roo/rules/subdir/subdir2/nested2.txt" + ? ".roo\\rules\\subdir\\subdir2\\nested2.txt" + : ".roo/rules/subdir/subdir2/nested2.txt" - expect(result).toContain(`# Rules from ${expectedRootPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeRootPath}:`) expect(result).toContain("root file content") // Check nested files content - expect(result).toContain(`# Rules from ${expectedNested1Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeNested1Path}:`) expect(result).toContain("nested file 1 content") - expect(result).toContain(`# Rules from ${expectedNested2Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeNested2Path}:`) expect(result).toContain("nested file 2 content") - // Verify correct paths were checked + // Verify correct absolute paths were checked internally const expectedRootPath2 = process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\root.txt" : "/fake/path/.roo/rules/root.txt" const expectedNested1Path2 = @@ -1053,39 +1059,34 @@ describe("addCustomInstructions", () => { { language: "es" }, ) - const expectedTestModeDir = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode" - const expectedRule1Path = - process.platform === "win32" - ? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt" - : "/fake/path/.roo/rules-test-mode/rule1.txt" - const expectedRule2Path = - process.platform === "win32" - ? "\\fake\\path\\.roo\\rules-test-mode\\rule2.txt" - : "/fake/path/.roo/rules-test-mode/rule2.txt" + // Paths in output should be relative + const expectedRelativeRule1Path = + process.platform === "win32" ? ".roo\\rules-test-mode\\rule1.txt" : ".roo/rules-test-mode/rule1.txt" + const expectedRelativeRule2Path = + process.platform === "win32" ? ".roo\\rules-test-mode\\rule2.txt" : ".roo/rules-test-mode/rule2.txt" - expect(result).toContain(`# Rules from ${expectedTestModeDir}`) - expect(result).toContain(`# Rules from ${expectedRule1Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeRule1Path}:`) expect(result).toContain("mode specific rule 1") - expect(result).toContain(`# Rules from ${expectedRule2Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeRule2Path}:`) expect(result).toContain("mode specific rule 2") - const expectedTestModeDir2 = + // Verify absolute paths were used internally + const expectedAbsTestModeDir = process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode" - const expectedRule1Path2 = + const expectedAbsRule1Path = process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt" : "/fake/path/.roo/rules-test-mode/rule1.txt" - const expectedRule2Path2 = + const expectedAbsRule2Path = process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode\\rule2.txt" : "/fake/path/.roo/rules-test-mode/rule2.txt" - expect(statMock).toHaveBeenCalledWith(expectedTestModeDir2) - expect(statMock).toHaveBeenCalledWith(expectedRule1Path2) - expect(statMock).toHaveBeenCalledWith(expectedRule2Path2) - expect(readFileMock).toHaveBeenCalledWith(expectedRule1Path2, "utf-8") - expect(readFileMock).toHaveBeenCalledWith(expectedRule2Path2, "utf-8") + expect(statMock).toHaveBeenCalledWith(expectedAbsTestModeDir) + expect(statMock).toHaveBeenCalledWith(expectedAbsRule1Path) + expect(statMock).toHaveBeenCalledWith(expectedAbsRule2Path) + expect(readFileMock).toHaveBeenCalledWith(expectedAbsRule1Path, "utf-8") + expect(readFileMock).toHaveBeenCalledWith(expectedAbsRule2Path, "utf-8") }) it("should fall back to .roorules-test-mode when .roo/rules-test-mode/ does not exist", async () => { @@ -1188,15 +1189,11 @@ describe("addCustomInstructions", () => { "test-mode", ) - const expectedTestModeDir = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode" - const expectedRule1Path = - process.platform === "win32" - ? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt" - : "/fake/path/.roo/rules-test-mode/rule1.txt" + // Paths in output should be relative + const expectedRelativeRule1Path = + process.platform === "win32" ? ".roo\\rules-test-mode\\rule1.txt" : ".roo/rules-test-mode/rule1.txt" - expect(result).toContain(`# Rules from ${expectedTestModeDir}`) - expect(result).toContain(`# Rules from ${expectedRule1Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeRule1Path}:`) expect(result).toContain("mode specific rule content") expect(statCallCount).toBeGreaterThan(0) @@ -1338,31 +1335,25 @@ describe("Rules directory reading", () => { const result = await loadRuleFiles("/fake/path") - // Verify both regular file and symlink target content are included - const expectedRegularPath = + // Verify both regular file and symlink target content are included (paths should be relative) + const expectedRelativeRegularPath = + process.platform === "win32" ? ".roo\\rules\\regular.txt" : ".roo/rules/regular.txt" + const expectedRelativeSymlinkPath = + process.platform === "win32" ? ".roo\\symlink-target.txt" : ".roo/symlink-target.txt" + const expectedRelativeSubdirPath = process.platform === "win32" - ? "\\fake\\path\\.roo\\rules\\regular.txt" - : "/fake/path/.roo/rules/regular.txt" - const expectedSymlinkPath = - process.platform === "win32" - ? "\\fake\\path\\.roo\\symlink-target.txt" - : "/fake/path/.roo/symlink-target.txt" - const expectedSubdirPath = - process.platform === "win32" - ? "\\fake\\path\\.roo\\rules\\symlink-target-dir\\subdir_link.txt" - : "/fake/path/.roo/rules/symlink-target-dir/subdir_link.txt" - const expectedNestedPath = - process.platform === "win32" - ? "\\fake\\path\\.roo\\nested-symlink-target.txt" - : "/fake/path/.roo/nested-symlink-target.txt" + ? ".roo\\rules\\symlink-target-dir\\subdir_link.txt" + : ".roo/rules/symlink-target-dir/subdir_link.txt" + const expectedRelativeNestedPath = + process.platform === "win32" ? ".roo\\nested-symlink-target.txt" : ".roo/nested-symlink-target.txt" - expect(result).toContain(`# Rules from ${expectedRegularPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeRegularPath}:`) expect(result).toContain("regular file content") - expect(result).toContain(`# Rules from ${expectedSymlinkPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeSymlinkPath}:`) expect(result).toContain("symlink target content") - expect(result).toContain(`# Rules from ${expectedSubdirPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeSubdirPath}:`) expect(result).toContain("regular file content under symlink target dir") - expect(result).toContain(`# Rules from ${expectedNestedPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeNestedPath}:`) expect(result).toContain("nested symlink target content") // Verify readlink was called with the symlink path @@ -1424,18 +1415,19 @@ describe("Rules directory reading", () => { const result = await loadRuleFiles("/fake/path") - const expectedFile1Path = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt" - const expectedFile2Path = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file2.txt" : "/fake/path/.roo/rules/file2.txt" - const expectedFile3Path = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file3.txt" : "/fake/path/.roo/rules/file3.txt" + // Paths in output should be relative + const expectedRelativeFile1Path = + process.platform === "win32" ? ".roo\\rules\\file1.txt" : ".roo/rules/file1.txt" + const expectedRelativeFile2Path = + process.platform === "win32" ? ".roo\\rules\\file2.txt" : ".roo/rules/file2.txt" + const expectedRelativeFile3Path = + process.platform === "win32" ? ".roo\\rules\\file3.txt" : ".roo/rules/file3.txt" - expect(result).toContain(`# Rules from ${expectedFile1Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeFile1Path}:`) expect(result).toContain("content of file1") - expect(result).toContain(`# Rules from ${expectedFile2Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeFile2Path}:`) expect(result).toContain("content of file2") - expect(result).toContain(`# Rules from ${expectedFile3Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeFile3Path}:`) expect(result).toContain("content of file3") }) @@ -1483,17 +1475,16 @@ describe("Rules directory reading", () => { 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" + // Verify the expected file paths are in the result (should be relative) + const expectedRelativeAlphaPath = + process.platform === "win32" ? ".roo\\rules\\alpha.txt" : ".roo/rules/alpha.txt" + const expectedRelativeBetaPath = process.platform === "win32" ? ".roo\\rules\\Beta.txt" : ".roo/rules/Beta.txt" + const expectedRelativeZebraPath = + process.platform === "win32" ? ".roo\\rules\\zebra.txt" : ".roo/rules/zebra.txt" - expect(result).toContain(`# Rules from ${expectedAlphaPath}:`) - expect(result).toContain(`# Rules from ${expectedBetaPath}:`) - expect(result).toContain(`# Rules from ${expectedZebraPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeAlphaPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeBetaPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeZebraPath}:`) }) it("should sort symlinks by their symlink names, not target names", async () => { diff --git a/src/core/prompts/sections/__tests__/skills.spec.ts b/src/core/prompts/sections/__tests__/skills.spec.ts new file mode 100644 index 0000000000..707d151252 --- /dev/null +++ b/src/core/prompts/sections/__tests__/skills.spec.ts @@ -0,0 +1,32 @@ +import { getSkillsSection } from "../skills" + +describe("getSkillsSection", () => { + it("should emit XML with name, description, and location", async () => { + const mockSkillsManager = { + getSkillsForMode: vi.fn().mockReturnValue([ + { + name: "pdf-processing", + description: "Extracts text & tables from PDFs", + path: "/abs/path/pdf-processing/SKILL.md", + source: "global" as const, + }, + ]), + } + + const result = await getSkillsSection(mockSkillsManager, "code") + + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain("pdf-processing") + // Ensure XML escaping for '&' + expect(result).toContain("Extracts text & tables from PDFs") + // For filesystem-based agents, location should be the absolute path to SKILL.md + expect(result).toContain("/abs/path/pdf-processing/SKILL.md") + }) + + it("should return empty string when skillsManager or currentMode is missing", async () => { + await expect(getSkillsSection(undefined, "code")).resolves.toBe("") + await expect(getSkillsSection({ getSkillsForMode: vi.fn() }, undefined)).resolves.toBe("") + }) +}) diff --git a/src/core/prompts/sections/__tests__/system-info.spec.ts b/src/core/prompts/sections/__tests__/system-info.spec.ts new file mode 100644 index 0000000000..749b53a0fd --- /dev/null +++ b/src/core/prompts/sections/__tests__/system-info.spec.ts @@ -0,0 +1,66 @@ +import os from "os" + +// Mock the modules - must be hoisted before imports +vi.mock("os-name", () => ({ + default: vi.fn(), +})) + +vi.mock("../../../../utils/shell", () => ({ + getShell: vi.fn(() => "/bin/bash"), +})) + +import { getSystemInfoSection } from "../system-info" +import osName from "os-name" + +const mockOsName = osName as unknown as ReturnType + +describe("getSystemInfoSection", () => { + const mockCwd = "/test/workspace" + const mockHomeDir = "/home/user" + + beforeEach(() => { + vi.spyOn(os, "homedir").mockReturnValue(mockHomeDir) + vi.spyOn(os, "platform").mockReturnValue("linux" as any) + vi.spyOn(os, "release").mockReturnValue("5.15.0") + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("should return system info with os-name when available", () => { + mockOsName.mockReturnValue("Ubuntu 22.04") + + const result = getSystemInfoSection(mockCwd) + + expect(result).toContain("Operating System: Ubuntu 22.04") + expect(result).toContain("Default Shell: /bin/bash") + expect(result).toContain(`Home Directory: ${mockHomeDir}`) + expect(result).toContain(`Current Workspace Directory: ${mockCwd}`) + }) + + it("should fallback to platform and release when os-name throws error", () => { + mockOsName.mockImplementation(() => { + throw new Error("Command failed with ENOENT: powershell") + }) + + const result = getSystemInfoSection(mockCwd) + + expect(result).toContain("Operating System: linux 5.15.0") + expect(result).toContain("Default Shell: /bin/bash") + expect(result).toContain(`Home Directory: ${mockHomeDir}`) + expect(result).toContain(`Current Workspace Directory: ${mockCwd}`) + }) + + it("should handle Windows platform in fallback", () => { + mockOsName.mockImplementation(() => { + throw new Error("Command failed with ENOENT: powershell") + }) + vi.spyOn(os, "platform").mockReturnValue("win32" as any) + vi.spyOn(os, "release").mockReturnValue("10.0.19043") + + const result = getSystemInfoSection(mockCwd) + + expect(result).toContain("Operating System: win32 10.0.19043") + }) +}) diff --git a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts index 3714f57c41..3cb3fb51d0 100644 --- a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts @@ -1,5 +1,6 @@ import { getToolUseGuidelinesSection } from "../tool-use-guidelines" import { TOOL_PROTOCOL } from "@roo-code/types" +import { EXPERIMENT_IDS } from "../../../../shared/experiments" describe("getToolUseGuidelinesSection", () => { describe("XML protocol", () => { @@ -35,30 +36,53 @@ describe("getToolUseGuidelinesSection", () => { }) describe("native protocol", () => { - it("should include proper numbered guidelines", () => { - const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE) + describe("with MULTIPLE_NATIVE_TOOL_CALLS disabled (default)", () => { + it("should include proper numbered guidelines", () => { + const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE) - // Check that all numbered items are present with correct numbering - expect(guidelines).toContain("1. Assess what information") - expect(guidelines).toContain("2. Choose the most appropriate tool") - expect(guidelines).toContain("3. If multiple actions are needed") - expect(guidelines).toContain("4. After each tool use") + // Check that all numbered items are present with correct numbering + expect(guidelines).toContain("1. Assess what information") + expect(guidelines).toContain("2. Choose the most appropriate tool") + expect(guidelines).toContain("3. If multiple actions are needed") + expect(guidelines).toContain("4. After each tool use") + }) + + it("should include single-tool-per-message guidance when experiment disabled", () => { + const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE, {}) + + expect(guidelines).toContain("use one tool at a time per message") + expect(guidelines).not.toContain("you may use multiple tools in a single message") + expect(guidelines).not.toContain("Formulate your tool use using the XML format") + expect(guidelines).not.toContain("ALWAYS wait for user confirmation") + }) + + it("should include simplified iterative process guidelines", () => { + const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE) + + expect(guidelines).toContain("carefully considering the user's response after tool executions") + // Native protocol doesn't have the step-by-step list + expect(guidelines).not.toContain("It is crucial to proceed step-by-step") + }) }) - it("should include native protocol-specific guidelines", () => { - const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE) + describe("with MULTIPLE_NATIVE_TOOL_CALLS enabled", () => { + it("should include multiple-tools-per-message guidance when experiment enabled", () => { + const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE, { + [EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS]: true, + }) - expect(guidelines).toContain("you may use multiple tools in a single message") - expect(guidelines).not.toContain("Formulate your tool use using the XML format") - expect(guidelines).not.toContain("ALWAYS wait for user confirmation") - }) + expect(guidelines).toContain("you may use multiple tools in a single message") + expect(guidelines).not.toContain("use one tool at a time per message") + }) - it("should include simplified iterative process guidelines", () => { - const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE) + it("should include simplified iterative process guidelines", () => { + const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE, { + [EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS]: true, + }) - expect(guidelines).toContain("carefully considering the user's response after tool executions") - // Native protocol doesn't have the step-by-step list - expect(guidelines).not.toContain("It is crucial to proceed step-by-step") + expect(guidelines).toContain("carefully considering the user's response after tool executions") + expect(guidelines).not.toContain("It is crucial to proceed step-by-step") + }) }) }) diff --git a/src/core/prompts/sections/__tests__/tool-use.spec.ts b/src/core/prompts/sections/__tests__/tool-use.spec.ts new file mode 100644 index 0000000000..c8e3a9b5d0 --- /dev/null +++ b/src/core/prompts/sections/__tests__/tool-use.spec.ts @@ -0,0 +1,69 @@ +import { getSharedToolUseSection } from "../tool-use" +import { TOOL_PROTOCOL } from "@roo-code/types" + +describe("getSharedToolUseSection", () => { + describe("XML protocol", () => { + it("should include one tool per message requirement", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.XML) + + expect(section).toContain("You must use exactly one tool per message") + expect(section).toContain("every assistant message must include a tool call") + }) + + it("should include XML formatting instructions", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.XML) + + expect(section).toContain("XML-style tags") + expect(section).toContain("Always use the actual tool name as the XML tag name") + }) + }) + + describe("native protocol", () => { + it("should include one tool per message requirement when experiment is disabled", () => { + // No experiment flags passed (default: disabled) + const section = getSharedToolUseSection(TOOL_PROTOCOL.NATIVE) + + expect(section).toContain("You must use exactly one tool call per assistant response") + expect(section).toContain("Do not call zero tools or more than one tool") + }) + + it("should include one tool per message requirement when experiment is explicitly disabled", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.NATIVE, { multipleNativeToolCalls: false }) + + expect(section).toContain("You must use exactly one tool call per assistant response") + expect(section).toContain("Do not call zero tools or more than one tool") + }) + + it("should NOT include one tool per message requirement when experiment is enabled", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.NATIVE, { multipleNativeToolCalls: true }) + + expect(section).not.toContain("You must use exactly one tool per message") + expect(section).not.toContain("every assistant message must include a tool call") + expect(section).toContain("You must call at least one tool per assistant response") + expect(section).toContain("Prefer calling as many tools as are reasonably needed") + }) + + it("should include native tool-calling instructions", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.NATIVE) + + expect(section).toContain("provider-native tool-calling mechanism") + expect(section).toContain("Do not include XML markup or examples") + }) + + it("should NOT include XML formatting instructions", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.NATIVE) + + expect(section).not.toContain("XML-style tags") + expect(section).not.toContain("Always use the actual tool name as the XML tag name") + }) + }) + + describe("default protocol", () => { + it("should default to XML protocol when no protocol is specified", () => { + const section = getSharedToolUseSection() + + expect(section).toContain("XML-style tags") + expect(section).toContain("You must use exactly one tool per message") + }) + }) +}) diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index a81d4bf943..ed33f4a1e3 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -9,7 +9,12 @@ import type { SystemPromptSettings } from "../types" import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types" import { LANGUAGES } from "../../../shared/language" -import { getRooDirectoriesForCwd, getGlobalRooDirectory } from "../../../services/roo-config" +import { + getRooDirectoriesForCwd, + getAllRooDirectoriesForCwd, + getAgentsDirectoriesForCwd, + getGlobalRooDirectory, +} from "../../../services/roo-config" /** * Safely read a file and return its trimmed content @@ -87,9 +92,15 @@ async function resolveSymLink( const stats = await fs.stat(resolvedTarget) if (stats.isFile()) { // For symlinks to files, store the symlink path as original and target as resolved - fileInfo.push({ originalPath: symlinkPath, resolvedPath: resolvedTarget }) + fileInfo.push({ + originalPath: symlinkPath, + resolvedPath: resolvedTarget, + }) } else if (stats.isDirectory()) { - const anotherEntries = await fs.readdir(resolvedTarget, { withFileTypes: true, recursive: true }) + 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) { @@ -111,7 +122,10 @@ async function resolveSymLink( */ async function readTextFilesFromDirectory(dirPath: string): Promise> { try { - const entries = await fs.readdir(dirPath, { withFileTypes: true, recursive: true }) + const entries = await fs.readdir(dirPath, { + withFileTypes: true, + recursive: true, + }) // Process all entries - regular files and symlinks that might point to files // Store both original path (for sorting) and resolved path (for reading) @@ -168,32 +182,40 @@ async function readTextFilesFromDirectory(dirPath: string): Promise): string { +function formatDirectoryContent(files: Array<{ filename: string; content: string }>, cwd: string): string { if (files.length === 0) return "" return files .map((file) => { - return `# Rules from ${file.filename}:\n${file.content}` + // Compute relative path for display + const displayPath = path.relative(cwd, file.filename) + return `# Rules from ${displayPath}:\n${file.content}` }) .join("\n\n") } /** - * Load rule files from global and project-local directories - * Global rules are loaded first, then project-local rules which can override global ones + * Load rule files from global, project-local, and optionally subfolder directories + * Rules are loaded in order: global first, then project-local, then subfolders (alphabetically) + * + * @param cwd - Current working directory (project root) + * @param enableSubfolderRules - Whether to include rules from subdirectories (default: false) */ -export async function loadRuleFiles(cwd: string): Promise { +export async function loadRuleFiles(cwd: string, enableSubfolderRules: boolean = false): Promise { const rules: string[] = [] - const rooDirectories = getRooDirectoriesForCwd(cwd) + // Use recursive discovery only if enableSubfolderRules is true + const rooDirectories = enableSubfolderRules ? await getAllRooDirectoriesForCwd(cwd) : getRooDirectoriesForCwd(cwd) - // Check for .roo/rules/ directories in order (global first, then project-local) + // Check for .roo/rules/ directories in order (global, project-local, and optionally subfolders) for (const rooDir of rooDirectories) { const rulesDir = path.join(rooDir, "rules") if (await directoryExists(rulesDir)) { const files = await readTextFilesFromDirectory(rulesDir) if (files.length > 0) { - const content = formatDirectoryContent(rulesDir, files) + const content = formatDirectoryContent(files, cwd) rules.push(content) } } @@ -201,7 +223,7 @@ export async function loadRuleFiles(cwd: string): Promise { // If we found rules in .roo/rules/ directories, return them if (rules.length > 0) { - return "\n" + rules.join("\n\n") + return "\n# Rules from .roo directories:\n\n" + rules.join("\n\n") } // Fall back to existing behavior for legacy .roorules/.clinerules files @@ -218,16 +240,24 @@ export async function loadRuleFiles(cwd: string): Promise { } /** - * Load AGENTS.md or AGENT.md file from the project root if it exists + * Load AGENTS.md or AGENT.md file from a specific directory * Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility + * + * @param directory - Directory to check for AGENTS.md + * @param showPath - Whether to include the directory path in the header + * @param cwd - Current working directory for computing relative paths (optional) */ -async function loadAgentRulesFile(cwd: string): Promise { +async function loadAgentRulesFileFromDirectory( + directory: string, + showPath: boolean = false, + cwd?: string, +): Promise { // Try both filenames - AGENTS.md (standard) first, then AGENT.md (alternative) const filenames = ["AGENTS.md", "AGENT.md"] for (const filename of filenames) { try { - const agentPath = path.join(cwd, filename) + const agentPath = path.join(directory, filename) let resolvedPath = agentPath // Check if file exists and handle symlinks @@ -235,7 +265,10 @@ async function loadAgentRulesFile(cwd: string): Promise { const stats = await fs.lstat(agentPath) if (stats.isSymbolicLink()) { // Create a temporary fileInfo array to use with resolveSymLink - const fileInfo: Array<{ originalPath: string; resolvedPath: string }> = [] + const fileInfo: Array<{ + originalPath: string + resolvedPath: string + }> = [] // Use the existing resolveSymLink function to handle symlink resolution await resolveSymLink(agentPath, fileInfo, 0) @@ -253,7 +286,12 @@ async function loadAgentRulesFile(cwd: string): Promise { // Read the content from the resolved path const content = await safeReadFile(resolvedPath) if (content) { - return `# Agent Rules Standard (${filename}):\n${content}` + // Compute relative path for display if cwd is provided + const displayPath = cwd ? path.relative(cwd, directory) : directory + const header = showPath + ? `# Agent Rules Standard (${filename}) from ${displayPath}:` + : `# Agent Rules Standard (${filename}):` + return `${header}\n${content}` } } catch (err) { // Silently ignore errors - agent rules files are optional @@ -262,6 +300,51 @@ async function loadAgentRulesFile(cwd: string): Promise { return "" } +/** + * Load AGENTS.md or AGENT.md file from the project root if it exists + * Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility + * + * @deprecated Use loadAllAgentRulesFiles for loading from all directories + */ +async function loadAgentRulesFile(cwd: string): Promise { + return loadAgentRulesFileFromDirectory(cwd, false, cwd) +} + +/** + * Load all AGENTS.md files from project root and optionally subdirectories with .roo folders + * Returns combined content with clear path headers for each file + * + * @param cwd - Current working directory (project root) + * @param enableSubfolderRules - Whether to include AGENTS.md from subdirectories (default: false) + * @returns Combined AGENTS.md content from all locations + */ +async function loadAllAgentRulesFiles(cwd: string, enableSubfolderRules: boolean = false): Promise { + const agentRules: string[] = [] + + // When subfolder rules are disabled, only load from root + if (!enableSubfolderRules) { + const content = await loadAgentRulesFileFromDirectory(cwd, false, cwd) + if (content && content.trim()) { + agentRules.push(content.trim()) + } + return agentRules.join("\n\n") + } + + // When enabled, load from root and all subdirectories with .roo folders + const directories = await getAgentsDirectoriesForCwd(cwd) + + for (const directory of directories) { + // Show path for all directories except the root + const showPath = directory !== cwd + const content = await loadAgentRulesFileFromDirectory(directory, showPath, cwd) + if (content && content.trim()) { + agentRules.push(content.trim()) + } + } + + return agentRules.join("\n\n") +} + export async function addCustomInstructions( modeCustomInstructions: string, globalCustomInstructions: string, @@ -275,21 +358,27 @@ export async function addCustomInstructions( ): Promise { const sections = [] + // Get the enableSubfolderRules setting (default: false) + const enableSubfolderRules = options.settings?.enableSubfolderRules ?? false + // Load mode-specific rules if mode is provided let modeRuleContent = "" let usedRuleFile = "" if (mode) { const modeRules: string[] = [] - const rooDirectories = getRooDirectoriesForCwd(cwd) + // Use recursive discovery only if enableSubfolderRules is true + const rooDirectories = enableSubfolderRules + ? await getAllRooDirectoriesForCwd(cwd) + : getRooDirectoriesForCwd(cwd) - // Check for .roo/rules-${mode}/ directories in order (global first, then project-local) + // Check for .roo/rules-${mode}/ directories in order (global, project-local, and optionally subfolders) for (const rooDir of rooDirectories) { const modeRulesDir = path.join(rooDir, `rules-${mode}`) if (await directoryExists(modeRulesDir)) { const files = await readTextFilesFromDirectory(modeRulesDir) if (files.length > 0) { - const content = formatDirectoryContent(modeRulesDir, files) + const content = formatDirectoryContent(files, cwd) modeRules.push(content) } } @@ -350,15 +439,16 @@ export async function addCustomInstructions( } // Add AGENTS.md content if enabled (default: true) + // Load from root and optionally subdirectories with .roo folders based on enableSubfolderRules setting if (options.settings?.useAgentRules !== false) { - const agentRulesContent = await loadAgentRulesFile(cwd) + const agentRulesContent = await loadAllAgentRulesFiles(cwd, enableSubfolderRules) if (agentRulesContent && agentRulesContent.trim()) { rules.push(agentRulesContent.trim()) } } // Add generic rules - const genericRuleContent = await loadRuleFiles(cwd) + const genericRuleContent = await loadRuleFiles(cwd, enableSubfolderRules) if (genericRuleContent && genericRuleContent.trim()) { rules.push(genericRuleContent.trim()) } diff --git a/src/core/prompts/sections/index.ts b/src/core/prompts/sections/index.ts index d06dbbfde1..b88cddc15c 100644 --- a/src/core/prompts/sections/index.ts +++ b/src/core/prompts/sections/index.ts @@ -8,3 +8,4 @@ export { getToolUseGuidelinesSection } from "./tool-use-guidelines" export { getCapabilitiesSection } from "./capabilities" export { getModesSection } from "./modes" export { markdownFormattingSection } from "./markdown-formatting" +export { getSkillsSection } from "./skills" diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index 20f0897022..800fb430ef 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -1,6 +1,53 @@ import type { SystemPromptSettings } from "../types" import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types" +import { getShell } from "../../../utils/shell" + +/** + * Returns the appropriate command chaining operator based on the user's shell. + * - Unix shells (bash, zsh, etc.): `&&` (run next command only if previous succeeds) + * - PowerShell: `;` (semicolon for command separation) + * - cmd.exe: `&&` (conditional execution, same as Unix) + * @internal Exported for testing purposes + */ +export function getCommandChainOperator(): string { + const shell = getShell().toLowerCase() + + // Check for PowerShell (both Windows PowerShell and PowerShell Core) + if (shell.includes("powershell") || shell.includes("pwsh")) { + return ";" + } + + // Check for cmd.exe + if (shell.includes("cmd.exe")) { + return "&&" + } + + // Default to Unix-style && for bash, zsh, sh, and other shells + // This also covers Git Bash, WSL, and other Unix-like environments on Windows + return "&&" +} + +/** + * Returns a shell-specific note about command chaining syntax and platform-specific utilities. + */ +function getCommandChainNote(): string { + const shell = getShell().toLowerCase() + + // Check for PowerShell + if (shell.includes("powershell") || shell.includes("pwsh")) { + return "Note: Using `;` for PowerShell command chaining. For bash/zsh use `&&`, for cmd.exe use `&&`. IMPORTANT: When using PowerShell, avoid Unix-specific utilities like `sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`. Instead use PowerShell equivalents: `Select-String` for grep, `Get-Content` for cat, `Remove-Item` for rm, `Copy-Item` for cp, `Move-Item` for mv, and PowerShell's `-replace` operator or `[regex]` for sed." + } + + // Check for cmd.exe + if (shell.includes("cmd.exe")) { + return "Note: Using `&&` for cmd.exe command chaining (conditional execution). For bash/zsh use `&&`, for PowerShell use `;`. IMPORTANT: When using cmd.exe, avoid Unix-specific utilities like `sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`. Use built-in commands like `type` for cat, `del` for rm, `copy` for cp, `move` for mv, `find`/`findstr` for grep, or consider using PowerShell commands instead." + } + + // Unix shells + return "" +} + function getVendorConfidentialitySection(): string { return ` @@ -20,6 +67,10 @@ export function getRulesSection(cwd: string, settings?: SystemPromptSettings): s // Determine whether to use XML tool references based on protocol const effectiveProtocol = getEffectiveProtocol(settings?.toolProtocol) + // Get shell-appropriate command chaining operator + const chainOp = getCommandChainOperator() + const chainNote = getCommandChainNote() + return `==== RULES @@ -28,7 +79,7 @@ RULES - All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to ${isNativeProtocol(effectiveProtocol) ? "execute_command" : ""}. - You cannot \`cd\` into a different directory to complete a task. You are stuck operating from '${cwd.toPosix()}', so be sure to pass in the correct 'path' parameter when using tools that require a path. - Do not use the ~ character or $HOME to refer to the home directory. -- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory && then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) && (command, in this case npm install)\`. +- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory ${chainOp} then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) ${chainOp} (command, in this case npm install)\`.${chainNote ? ` ${chainNote}` : ""} - Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode. - Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write. * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\\.md$" diff --git a/src/core/prompts/sections/skills.ts b/src/core/prompts/sections/skills.ts new file mode 100644 index 0000000000..954d0451bd --- /dev/null +++ b/src/core/prompts/sections/skills.ts @@ -0,0 +1,96 @@ +import type { SkillsManager } from "../../../services/skills/SkillsManager" + +type SkillsManagerLike = Pick + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'") +} + +/** + * Generate the skills section for the system prompt. + * Only includes skills relevant to the current mode. + * Format matches the modes section style. + * + * @param skillsManager - The SkillsManager instance + * @param currentMode - The current mode slug (e.g., 'code', 'architect') + */ +export async function getSkillsSection( + skillsManager: SkillsManagerLike | undefined, + currentMode: string | undefined, +): Promise { + if (!skillsManager || !currentMode) return "" + + // Get skills filtered by current mode (with override resolution) + const skills = skillsManager.getSkillsForMode(currentMode) + if (skills.length === 0) return "" + + const skillsXml = skills + .map((skill) => { + const name = escapeXml(skill.name) + const description = escapeXml(skill.description) + // Per the Agent Skills integration guidance for filesystem-based agents, + // location should be an absolute path to the SKILL.md file. + const location = escapeXml(skill.path) + return ` \n ${name}\n ${description}\n ${location}\n ` + }) + .join("\n") + + return `==== + +AVAILABLE SKILLS + + +${skillsXml} + + + +REQUIRED PRECONDITION + +Before producing ANY user-facing response, you MUST perform a skill applicability check. + +Step 1: Skill Evaluation +- Evaluate the user's request against ALL available skill entries in . +- Determine whether at least one skill clearly and unambiguously applies. + +Step 2: Branching Decision + + +- Select EXACTLY ONE skill. +- Prefer the most specific skill when multiple skills match. +- Read the full SKILL.md file at the skill's . +- Load the SKILL.md contents fully into context BEFORE continuing. +- Follow the SKILL.md instructions precisely. +- Do NOT respond outside the skill-defined flow. + + + +- Proceed with a normal response. +- Do NOT load any SKILL.md files. + + +CONSTRAINTS: +- Do NOT load every SKILL.md up front. +- Load SKILL.md ONLY after a skill is selected. +- Do NOT skip this check. +- FAILURE to perform this check is an error. + + + +- The skill list is already filtered for the current mode: "${currentMode}". +- Mode-specific skills may come from skills-${currentMode}/ with project-level overrides taking precedence over global skills. + + + +This section is for internal control only. +Do NOT include this section in user-facing output. + +After completing the evaluation, internally confirm: +true|false + +` +} diff --git a/src/core/prompts/sections/system-info.ts b/src/core/prompts/sections/system-info.ts index 8adc90a160..486e46ee23 100644 --- a/src/core/prompts/sections/system-info.ts +++ b/src/core/prompts/sections/system-info.ts @@ -4,11 +4,22 @@ import osName from "os-name" import { getShell } from "../../../utils/shell" export function getSystemInfoSection(cwd: string): string { + // Try to get detailed OS name, fall back to basic info if it fails + let osInfo: string + try { + osInfo = osName() + } catch (error) { + // Fallback when os-name fails (e.g., PowerShell not available on Windows) + const platform = os.platform() + const release = os.release() + osInfo = `${platform} ${release}` + } + let details = `==== SYSTEM INFORMATION -Operating System: ${osName()} +Operating System: ${osInfo} Default Shell: ${getShell()} Home Directory: ${os.homedir().toPosix()} Current Workspace Directory: ${cwd.toPosix()} diff --git a/src/core/prompts/sections/tool-use-guidelines.ts b/src/core/prompts/sections/tool-use-guidelines.ts index 0e0ca305c2..a5dad2cc0b 100644 --- a/src/core/prompts/sections/tool-use-guidelines.ts +++ b/src/core/prompts/sections/tool-use-guidelines.ts @@ -1,7 +1,12 @@ import { ToolProtocol, TOOL_PROTOCOL } from "@roo-code/types" import { isNativeProtocol } from "@roo-code/types" -export function getToolUseGuidelinesSection(protocol: ToolProtocol = TOOL_PROTOCOL.XML): string { +import { experiments, EXPERIMENT_IDS } from "../../../shared/experiments" + +export function getToolUseGuidelinesSection( + protocol: ToolProtocol = TOOL_PROTOCOL.XML, + experimentFlags?: Record, +): string { // Build guidelines array with automatic numbering let itemNumber = 1 const guidelinesList: string[] = [] @@ -17,9 +22,21 @@ export function getToolUseGuidelinesSection(protocol: ToolProtocol = TOOL_PROTOC // Remaining guidelines - different for native vs XML protocol if (isNativeProtocol(protocol)) { - guidelinesList.push( - `${itemNumber++}. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result.`, + // Check if multiple native tool calls is enabled via experiment + const isMultipleNativeToolCallsEnabled = experiments.isEnabled( + experimentFlags ?? {}, + EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS, ) + + if (isMultipleNativeToolCallsEnabled) { + guidelinesList.push( + `${itemNumber++}. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result.`, + ) + } else { + guidelinesList.push( + `${itemNumber++}. If multiple actions are needed, use one tool at a time per message to accomplish the task iteratively, with each tool use being informed by the result of the previous tool use. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result.`, + ) + } } else { guidelinesList.push( `${itemNumber++}. If multiple actions are needed, use one tool at a time per message to accomplish the task iteratively, with each tool use being informed by the result of the previous tool use. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result.`, diff --git a/src/core/prompts/sections/tool-use.ts b/src/core/prompts/sections/tool-use.ts index 9ece848fb4..c3f5e221b8 100644 --- a/src/core/prompts/sections/tool-use.ts +++ b/src/core/prompts/sections/tool-use.ts @@ -1,12 +1,27 @@ import { ToolProtocol, TOOL_PROTOCOL, isNativeProtocol } from "@roo-code/types" -export function getSharedToolUseSection(protocol: ToolProtocol = TOOL_PROTOCOL.XML): string { +import { experiments, EXPERIMENT_IDS } from "../../../shared/experiments" + +export function getSharedToolUseSection( + protocol: ToolProtocol = TOOL_PROTOCOL.XML, + experimentFlags?: Record, +): string { if (isNativeProtocol(protocol)) { + // Check if multiple native tool calls is enabled via experiment + const isMultipleNativeToolCallsEnabled = experiments.isEnabled( + experimentFlags ?? {}, + EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS, + ) + + const toolUseGuidance = isMultipleNativeToolCallsEnabled + ? " You must call at least one tool per assistant response. Prefer calling as many tools as are reasonably needed in a single response to reduce back-and-forth and complete tasks faster." + : " You must use exactly one tool call per assistant response. Do not call zero tools or more than one tool in the same response." + return `==== TOOL USE -You have access to a set of tools that are executed upon the user's approval. Use the provider-native tool-calling mechanism. Do not include XML markup or examples.` +You have access to a set of tools that are executed upon the user's approval. Use the provider-native tool-calling mechanism. Do not include XML markup or examples.${toolUseGuidance}` } return `==== diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index b654395037..040d703929 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -18,6 +18,7 @@ import { isEmpty } from "../../utils/object" import { McpHub } from "../../services/mcp/McpHub" import { CodeIndexManager } from "../../services/code-index/manager" +import { SkillsManager } from "../../services/skills/SkillsManager" import { PromptVariables, loadSystemPromptFile } from "./sections/custom-system-prompt" @@ -34,6 +35,7 @@ import { getModesSection, addCustomInstructions, markdownFormattingSection, + getSkillsSection, } from "./sections" // Helper function to get prompt component, filtering out empty objects @@ -69,6 +71,7 @@ async function generatePrompt( settings?: SystemPromptSettings, todoList?: TodoItem[], modelId?: string, + skillsManager?: SkillsManager, ): Promise { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -91,7 +94,7 @@ async function generatePrompt( // Determine the effective protocol (defaults to 'xml') const effectiveProtocol = getEffectiveProtocol(settings?.toolProtocol) - const [modesSection, mcpServersSection] = await Promise.all([ + const [modesSection, mcpServersSection, skillsSection] = await Promise.all([ getModesSection(context), shouldIncludeMcp ? getMcpServersSection( @@ -101,6 +104,7 @@ async function generatePrompt( !isNativeProtocol(effectiveProtocol), ) : Promise.resolve(""), + getSkillsSection(skillsManager, mode as string), ]) // Build tools catalog section only for XML protocol @@ -138,16 +142,16 @@ async function generatePrompt( ${markdownFormattingSection()} -${getSharedToolUseSection(effectiveProtocol)}${toolsCatalog} +${getSharedToolUseSection(effectiveProtocol, experiments)}${toolsCatalog} -${getToolUseGuidelinesSection(effectiveProtocol)} +${getToolUseGuidelinesSection(effectiveProtocol, experiments)} ${mcpServersSection} ${getCapabilitiesSection(cwd, shouldIncludeMcp ? mcpHub : undefined)} ${modesSection} - +${skillsSection ? `\n${skillsSection}` : ""} ${getRulesSection(cwd, settings)} ${getSystemInfoSection(cwd)} @@ -183,6 +187,7 @@ export const SYSTEM_PROMPT = async ( settings?: SystemPromptSettings, todoList?: TodoItem[], modelId?: string, + skillsManager?: SkillsManager, ): Promise => { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -255,5 +260,6 @@ ${customInstructions}` settings, todoList, modelId, + skillsManager, ) } diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index c9a69efae8..b75725a99b 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -1,5 +1,4 @@ import type { ToolName, ModeConfig } from "@roo-code/types" -import { shouldUseSingleFileRead } from "@roo-code/types" import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, DiffStrategy } from "../../../shared/tools" import { Mode, getModeConfig, getGroupName } from "../../../shared/modes" @@ -12,7 +11,6 @@ import { CodeIndexManager } from "../../../services/code-index/manager" import { ToolArgs } from "./types" import { getExecuteCommandDescription } from "./execute-command" import { getReadFileDescription } from "./read-file" -import { getSimpleReadFileDescription } from "./simple-read-file" import { getFetchInstructionsDescription } from "./fetch-instructions" import { getWriteToFileDescription } from "./write-to-file" import { getSearchFilesDescription } from "./search-files" @@ -32,14 +30,7 @@ import { getGenerateImageDescription } from "./generate-image" // Map of tool names to their description functions const toolDescriptionMap: Record string | undefined> = { execute_command: (args) => getExecuteCommandDescription(args), - read_file: (args) => { - // Check if the current model should use the simplified read_file tool - const modelId = args.settings?.modelId - if (modelId && shouldUseSingleFileRead(modelId)) { - return getSimpleReadFileDescription(args) - } - return getReadFileDescription(args) - }, + read_file: (args) => getReadFileDescription(args), fetch_instructions: (args) => getFetchInstructionsDescription(args.settings?.enableMcpServerCreation), write_to_file: (args) => getWriteToFileDescription(args), search_files: (args) => getSearchFilesDescription(args), @@ -162,7 +153,6 @@ export function getToolDescriptionsForMode( export { getExecuteCommandDescription, getReadFileDescription, - getSimpleReadFileDescription, getFetchInstructionsDescription, getWriteToFileDescription, getSearchFilesDescription, diff --git a/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts new file mode 100644 index 0000000000..9561fe417d --- /dev/null +++ b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts @@ -0,0 +1,243 @@ +import type OpenAI from "openai" +import { createReadFileTool, type ReadFileToolOptions } from "../read_file" + +// Helper type to access function tools +type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" } + +// Helper to get function definition from tool +const getFunctionDef = (tool: OpenAI.Chat.ChatCompletionTool) => (tool as FunctionTool).function + +describe("createReadFileTool", () => { + describe("maxConcurrentFileReads documentation", () => { + it("should include default maxConcurrentFileReads limit (5) in description", () => { + const tool = createReadFileTool() + const description = getFunctionDef(tool).description + + expect(description).toContain("maximum of 5 files") + expect(description).toContain("If you need to read more files, use multiple sequential read_file requests") + }) + + it("should include custom maxConcurrentFileReads limit in description", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 3 }) + const description = getFunctionDef(tool).description + + expect(description).toContain("maximum of 3 files") + expect(description).toContain("within 3-file limit") + }) + + it("should indicate single file reads only when maxConcurrentFileReads is 1", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 1 }) + const description = getFunctionDef(tool).description + + expect(description).toContain("Multiple file reads are currently disabled") + expect(description).toContain("only read one file at a time") + expect(description).not.toContain("Example multiple files") + }) + + it("should use singular 'Read a file' in base description when maxConcurrentFileReads is 1", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 1 }) + const description = getFunctionDef(tool).description + + expect(description).toMatch(/^Read a file/) + expect(description).not.toContain("Read one or more files") + }) + + it("should use plural 'Read one or more files' in base description when maxConcurrentFileReads is > 1", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 5 }) + const description = getFunctionDef(tool).description + + expect(description).toMatch(/^Read one or more files/) + }) + + it("should not show multiple files example when maxConcurrentFileReads is 1", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 1, partialReadsEnabled: true }) + const description = getFunctionDef(tool).description + + expect(description).not.toContain("Example multiple files") + }) + + it("should show multiple files example when maxConcurrentFileReads is > 1", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 5, partialReadsEnabled: true }) + const description = getFunctionDef(tool).description + + expect(description).toContain("Example multiple files") + }) + }) + + describe("partialReadsEnabled option", () => { + it("should include line_ranges in description when partialReadsEnabled is true", () => { + const tool = createReadFileTool({ partialReadsEnabled: true }) + const description = getFunctionDef(tool).description + + expect(description).toContain("line_ranges") + expect(description).toContain("Example with line ranges") + }) + + it("should not include line_ranges in description when partialReadsEnabled is false", () => { + const tool = createReadFileTool({ partialReadsEnabled: false }) + const description = getFunctionDef(tool).description + + expect(description).not.toContain("line_ranges") + expect(description).not.toContain("Example with line ranges") + }) + + it("should include line_ranges parameter in schema when partialReadsEnabled is true", () => { + const tool = createReadFileTool({ partialReadsEnabled: true }) + const schema = getFunctionDef(tool).parameters as any + + expect(schema.properties.files.items.properties).toHaveProperty("line_ranges") + }) + + it("should not include line_ranges parameter in schema when partialReadsEnabled is false", () => { + const tool = createReadFileTool({ partialReadsEnabled: false }) + const schema = getFunctionDef(tool).parameters as any + + expect(schema.properties.files.items.properties).not.toHaveProperty("line_ranges") + }) + }) + + describe("supportsImages option", () => { + it("should include image format documentation when supportsImages is true", () => { + const tool = createReadFileTool({ supportsImages: true }) + const description = getFunctionDef(tool).description + + expect(description).toContain( + "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", + ) + }) + + it("should not include image format documentation when supportsImages is false", () => { + const tool = createReadFileTool({ supportsImages: false }) + const description = getFunctionDef(tool).description + + expect(description).not.toContain( + "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", + ) + expect(description).toContain("may not handle other binary files properly") + }) + + it("should default supportsImages to false", () => { + const tool = createReadFileTool({}) + const description = getFunctionDef(tool).description + + expect(description).not.toContain( + "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", + ) + }) + + it("should always include PDF and DOCX support in description", () => { + const toolWithImages = createReadFileTool({ supportsImages: true }) + const toolWithoutImages = createReadFileTool({ supportsImages: false }) + + expect(getFunctionDef(toolWithImages).description).toContain( + "Supports text extraction from PDF and DOCX files", + ) + expect(getFunctionDef(toolWithoutImages).description).toContain( + "Supports text extraction from PDF and DOCX files", + ) + }) + }) + + describe("combined options", () => { + it("should correctly combine low maxConcurrentFileReads with partialReadsEnabled", () => { + const tool = createReadFileTool({ + maxConcurrentFileReads: 2, + partialReadsEnabled: true, + }) + const description = getFunctionDef(tool).description + + expect(description).toContain("maximum of 2 files") + expect(description).toContain("line_ranges") + expect(description).toContain("within 2-file limit") + }) + + it("should correctly handle maxConcurrentFileReads of 1 with partialReadsEnabled false", () => { + const tool = createReadFileTool({ + maxConcurrentFileReads: 1, + partialReadsEnabled: false, + }) + const description = getFunctionDef(tool).description + + expect(description).toContain("only read one file at a time") + expect(description).not.toContain("line_ranges") + expect(description).not.toContain("Example multiple files") + }) + + it("should correctly combine partialReadsEnabled and supportsImages", () => { + const tool = createReadFileTool({ + partialReadsEnabled: true, + supportsImages: true, + }) + const description = getFunctionDef(tool).description + + // Should have both line_ranges and image support + expect(description).toContain("line_ranges") + expect(description).toContain( + "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", + ) + }) + + it("should work with partialReadsEnabled=false and supportsImages=true", () => { + const tool = createReadFileTool({ + partialReadsEnabled: false, + supportsImages: true, + }) + const description = getFunctionDef(tool).description + + // Should have image support but no line_ranges + expect(description).not.toContain("line_ranges") + expect(description).toContain( + "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", + ) + }) + + it("should correctly combine all three options", () => { + const tool = createReadFileTool({ + maxConcurrentFileReads: 3, + partialReadsEnabled: true, + supportsImages: true, + }) + const description = getFunctionDef(tool).description + + expect(description).toContain("maximum of 3 files") + expect(description).toContain("line_ranges") + expect(description).toContain( + "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", + ) + }) + }) + + describe("tool structure", () => { + it("should have correct tool name", () => { + const tool = createReadFileTool() + + expect(getFunctionDef(tool).name).toBe("read_file") + }) + + it("should be a function type tool", () => { + const tool = createReadFileTool() + + expect(tool.type).toBe("function") + }) + + it("should have strict mode enabled", () => { + const tool = createReadFileTool() + + expect(getFunctionDef(tool).strict).toBe(true) + }) + + it("should require files parameter", () => { + const tool = createReadFileTool() + const schema = getFunctionDef(tool).parameters as any + + expect(schema.required).toContain("files") + }) + + it("should require path in file objects", () => { + const tool = createReadFileTool({ partialReadsEnabled: false }) + const schema = getFunctionDef(tool).parameters as any + + expect(schema.properties.files.items.required).toContain("path") + }) + }) +}) diff --git a/src/core/prompts/tools/native-tools/ask_followup_question.ts b/src/core/prompts/tools/native-tools/ask_followup_question.ts index f4f95b2ced..b0591206ad 100644 --- a/src/core/prompts/tools/native-tools/ask_followup_question.ts +++ b/src/core/prompts/tools/native-tools/ask_followup_question.ts @@ -51,7 +51,7 @@ export default { required: ["text", "mode"], additionalProperties: false, }, - minItems: 2, + minItems: 1, maxItems: 4, }, }, diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 79302a39f3..4f78729cdc 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -11,7 +11,7 @@ import fetchInstructions from "./fetch_instructions" import generateImage from "./generate_image" import listFiles from "./list_files" import newTask from "./new_task" -import { createReadFileTool } from "./read_file" +import { createReadFileTool, type ReadFileToolOptions } from "./read_file" import runSlashCommand from "./run_slash_command" import searchAndReplace from "./search_and_replace" import searchReplace from "./search_replace" @@ -23,14 +23,35 @@ import writeToFile from "./write_to_file" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" +export type { ReadFileToolOptions } from "./read_file" + +/** + * Options for customizing the native tools array. + */ +export interface NativeToolsOptions { + /** Whether to include line_ranges support in read_file tool (default: true) */ + partialReadsEnabled?: boolean + /** Maximum number of files that can be read in a single read_file request (default: 5) */ + maxConcurrentFileReads?: number + /** Whether the model supports image processing (default: false) */ + supportsImages?: boolean +} /** * Get native tools array, optionally customizing based on settings. * - * @param partialReadsEnabled - Whether to include line_ranges support in read_file tool (default: true) + * @param options - Configuration options for the tools * @returns Array of native tool definitions */ -export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat.ChatCompletionTool[] { +export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.ChatCompletionTool[] { + const { partialReadsEnabled = true, maxConcurrentFileReads = 5, supportsImages = false } = options + + const readFileOptions: ReadFileToolOptions = { + partialReadsEnabled, + maxConcurrentFileReads, + supportsImages, + } + return [ accessMcpResource, apply_diff, @@ -44,7 +65,7 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat generateImage, listFiles, newTask, - createReadFileTool(partialReadsEnabled), + createReadFileTool(readFileOptions), runSlashCommand, searchAndReplace, searchReplace, @@ -57,4 +78,4 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat } // Backward compatibility: export default tools with line ranges enabled -export const nativeTools = getNativeTools(true) +export const nativeTools = getNativeTools() diff --git a/src/core/prompts/tools/native-tools/read_file.ts b/src/core/prompts/tools/native-tools/read_file.ts index bf43f26c8a..7171be0f1d 100644 --- a/src/core/prompts/tools/native-tools/read_file.ts +++ b/src/core/prompts/tools/native-tools/read_file.ts @@ -1,20 +1,49 @@ import type OpenAI from "openai" -const READ_FILE_BASE_DESCRIPTION = `Read one or more files and return their contents with line numbers for diffing or discussion.` +/** + * Generates the file support note, optionally including image format support. + * + * @param supportsImages - Whether the model supports image processing + * @returns Support note string + */ +function getReadFileSupportsNote(supportsImages: boolean): string { + if (supportsImages) { + return `Supports text extraction from PDF and DOCX files. Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis. May not handle other binary files properly.` + } + return `Supports text extraction from PDF and DOCX files, but may not handle other binary files properly.` +} -const READ_FILE_SUPPORTS_NOTE = `Supports text extraction from PDF and DOCX files, but may not handle other binary files properly.` +/** + * Options for creating the read_file tool definition. + */ +export interface ReadFileToolOptions { + /** Whether to include line_ranges parameter (default: true) */ + partialReadsEnabled?: boolean + /** Maximum number of files that can be read in a single request (default: 5) */ + maxConcurrentFileReads?: number + /** Whether the model supports image processing (default: false) */ + supportsImages?: boolean +} /** * Creates the read_file tool definition, optionally including line_ranges support * based on whether partial reads are enabled. * - * @param partialReadsEnabled - Whether to include line_ranges parameter + * @param options - Configuration options for the tool * @returns Native tool definition for read_file */ -export function createReadFileTool(partialReadsEnabled: boolean = true): OpenAI.Chat.ChatCompletionTool { +export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Chat.ChatCompletionTool { + const { partialReadsEnabled = true, maxConcurrentFileReads = 5, supportsImages = false } = options + const isMultipleReadsEnabled = maxConcurrentFileReads > 1 + + // Build description intro with concurrent reads limit message + const descriptionIntro = isMultipleReadsEnabled + ? `Read one or more files and return their contents with line numbers for diffing or discussion. IMPORTANT: You can read a maximum of ${maxConcurrentFileReads} files in a single request. If you need to read more files, use multiple sequential read_file requests. ` + : "Read a file and return its contents with line numbers for diffing or discussion. IMPORTANT: Multiple file reads are currently disabled. You can only read one file at a time. " + const baseDescription = - READ_FILE_BASE_DESCRIPTION + - " Structure: { files: [{ path: 'relative/path.ts'" + + descriptionIntro + + "Structure: { files: [{ path: 'relative/path.ts'" + (partialReadsEnabled ? ", line_ranges: [[1, 50], [100, 150]]" : "") + " }] }. " + "The 'path' is required and relative to workspace. " @@ -26,11 +55,16 @@ export function createReadFileTool(partialReadsEnabled: boolean = true): OpenAI. const examples = partialReadsEnabled ? "Example single file: { files: [{ path: 'src/app.ts' }] }. " + "Example with line ranges: { files: [{ path: 'src/app.ts', line_ranges: [[1, 50], [100, 150]] }] }. " + - "Example multiple files: { files: [{ path: 'file1.ts', line_ranges: [[1, 50]] }, { path: 'file2.ts' }] }" + (isMultipleReadsEnabled + ? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts', line_ranges: [[1, 50]] }, { path: 'file2.ts' }] }` + : "") : "Example single file: { files: [{ path: 'src/app.ts' }] }. " + - "Example multiple files: { files: [{ path: 'file1.ts' }, { path: 'file2.ts' }] }" + (isMultipleReadsEnabled + ? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts' }, { path: 'file2.ts' }] }` + : "") - const description = baseDescription + optionalRangesDescription + READ_FILE_SUPPORTS_NOTE + " " + examples + const description = + baseDescription + optionalRangesDescription + getReadFileSupportsNote(supportsImages) + " " + examples // Build the properties object conditionally const fileProperties: Record = { @@ -87,4 +121,4 @@ export function createReadFileTool(partialReadsEnabled: boolean = true): OpenAI. } satisfies OpenAI.Chat.ChatCompletionTool } -export const read_file = createReadFileTool(false) +export const read_file = createReadFileTool({ partialReadsEnabled: false }) diff --git a/src/core/prompts/tools/native-tools/write_to_file.ts b/src/core/prompts/tools/native-tools/write_to_file.ts index 8119fd6764..b9e9b313a2 100644 --- a/src/core/prompts/tools/native-tools/write_to_file.ts +++ b/src/core/prompts/tools/native-tools/write_to_file.ts @@ -4,20 +4,16 @@ const WRITE_TO_FILE_DESCRIPTION = `Request to write content to a file. This tool **Important:** You should prefer using other editing tools over write_to_file when making changes to existing files, since write_to_file is slower and cannot handle large files. Use write_to_file primarily for new file creation. -When using this tool, use it directly with the desired content. You do not need to display the content before using the tool. ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. You MUST include ALL parts of the file, even if they haven't been modified. Failure to do so will result in incomplete or broken code. +When using this tool, use it directly with the desired content. You do not need to display the content before using the tool. ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. Failure to do so will result in incomplete or broken code. When creating a new project, organize all new files within a dedicated project directory unless the user specifies otherwise. Structure the project logically, adhering to best practices for the specific type of project being created. -Parameters: -- path: (required) The path of the file to write to (relative to the current workspace directory) -- content: (required) The content to write to the file. ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions. You MUST include ALL parts of the file, even if they haven't been modified. Do NOT include line numbers in the content. - Example: Writing a configuration file { "path": "frontend-config.json", "content": "{\\n \\"apiEndpoint\\": \\"https://api.example.com\\",\\n \\"theme\\": {\\n \\"primaryColor\\": \\"#007bff\\"\\n }\\n}" }` -const PATH_PARAMETER_DESCRIPTION = `Path to the file to write, relative to the workspace` +const PATH_PARAMETER_DESCRIPTION = `The path of the file to write to (relative to the current workspace directory)` -const CONTENT_PARAMETER_DESCRIPTION = `Full contents that the file should contain with no omissions or line numbers` +const CONTENT_PARAMETER_DESCRIPTION = `The content to write to the file. ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions. You MUST include ALL parts of the file, even if they haven't been modified. Do NOT include line numbers in the content.` export default { type: "function", diff --git a/src/core/prompts/tools/simple-read-file.ts b/src/core/prompts/tools/simple-read-file.ts deleted file mode 100644 index 28f4f1129e..0000000000 --- a/src/core/prompts/tools/simple-read-file.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ToolArgs } from "./types" - -/** - * Generate a simplified read_file tool description for models that only support single file reads - * Uses the simpler format: file/path.ext - */ -export function getSimpleReadFileDescription(args: ToolArgs): string { - return `## read_file -Description: Request to read the contents of a file. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when discussing code. - -Parameters: -- path: (required) File path (relative to workspace directory ${args.cwd}) - -Usage: - -path/to/file - - -Examples: - -1. Reading a TypeScript file: - -src/app.ts - - -2. Reading a configuration file: - -config.json - - -3. Reading a markdown file: - -README.md -` -} diff --git a/src/core/prompts/types.ts b/src/core/prompts/types.ts index 041027d1ec..0e27910c01 100644 --- a/src/core/prompts/types.ts +++ b/src/core/prompts/types.ts @@ -8,6 +8,8 @@ export interface SystemPromptSettings { todoListEnabled: boolean browserToolEnabled?: boolean useAgentRules: boolean + /** When true, recursively discover and load .roo/rules from subdirectories */ + enableSubfolderRules?: boolean newTaskRequireTodos: boolean toolProtocol?: ToolProtocol /** When true, model should hide vendor/company identity in responses */ diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index eb872a6f7e..cf8d9adb52 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -21,6 +21,8 @@ export type TaskMetadataOptions = { globalStoragePath: string workspace: string mode?: string + /** Provider profile name for the task (sticky profile feature) */ + apiConfigName?: string /** Initial status for the task (e.g., "active" for child tasks) */ initialStatus?: "active" | "delegated" | "completed" /** @@ -39,6 +41,7 @@ export async function taskMetadata({ globalStoragePath, workspace, mode, + apiConfigName, initialStatus, toolProtocol, }: TaskMetadataOptions) { @@ -116,6 +119,7 @@ export async function taskMetadata({ workspace, mode, ...(toolProtocol && { toolProtocol }), + ...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}), ...(initialStatus && { status: initialStatus }), } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 92ea6aa957..5fcfde37ef 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -247,6 +247,49 @@ export class Task extends EventEmitter implements TaskLike { */ private taskModeReady: Promise + /** + * The API configuration name (provider profile) associated with this task. + * Persisted across sessions to maintain the provider profile when reopening tasks from history. + * + * ## Lifecycle + * + * ### For new tasks: + * 1. Initially `undefined` during construction + * 2. Asynchronously initialized from provider state via `initializeTaskApiConfigName()` + * 3. Falls back to "default" if provider state is unavailable + * + * ### For history items: + * 1. Immediately set from `historyItem.apiConfigName` during construction + * 2. Falls back to undefined if not stored in history (for backward compatibility) + * + * ## Important + * If you need a non-`undefined` provider profile (e.g., for profile-dependent operations), + * wait for `taskApiConfigReady` first (or use `getTaskApiConfigName()`). + * The sync `taskApiConfigName` getter may return `undefined` for backward compatibility. + * + * @private + * @see {@link getTaskApiConfigName} - For safe async access + * @see {@link taskApiConfigName} - For sync access after initialization + */ + private _taskApiConfigName: string | undefined + + /** + * Promise that resolves when the task API config name has been initialized. + * This ensures async API config name initialization completes before the task is used. + * + * ## Purpose + * - Prevents race conditions when accessing task API config name + * - Ensures provider state is properly loaded before profile-dependent operations + * - Provides a synchronization point for async initialization + * + * ## Resolution timing + * - For history items: Resolves immediately (sync initialization) + * - For new tasks: Resolves after provider state is fetched (async initialization) + * + * @private + */ + private taskApiConfigReady: Promise + providerRef: WeakRef private readonly globalStoragePath: string abort: boolean = false @@ -311,6 +354,7 @@ export class Task extends EventEmitter implements TaskLike { consecutiveMistakeLimit: number consecutiveMistakeCountForApplyDiff: Map = new Map() consecutiveNoToolUseCount: number = 0 + consecutiveNoAssistantMessagesCount: number = 0 toolUsage: ToolUsage = {} // Checkpoints @@ -336,6 +380,28 @@ export class Task extends EventEmitter implements TaskLike { presentAssistantMessageHasPendingUpdates = false userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = [] userMessageContentReady = false + + /** + * Push a tool_result block to userMessageContent, preventing duplicates. + * This is critical for native tool protocol where duplicate tool_use_ids cause API errors. + * + * @param toolResult - The tool_result block to add + * @returns true if added, false if duplicate was skipped + */ + public pushToolResultToUserContent(toolResult: Anthropic.ToolResultBlockParam): boolean { + const existingResult = this.userMessageContent.find( + (block): block is Anthropic.ToolResultBlockParam => + block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + console.warn( + `[Task#pushToolResultToUserContent] Skipping duplicate tool_result for tool_use_id: ${toolResult.tool_use_id}`, + ) + return false + } + this.userMessageContent.push(toolResult) + return true + } didRejectTool = false didAlreadyUseTool = false didToolFailInCurrentTurn = false @@ -479,21 +545,25 @@ export class Task extends EventEmitter implements TaskLike { this.taskNumber = taskNumber this.initialStatus = initialStatus - // Store the task's mode when it's created. - // For history items, use the stored mode; for new tasks, we'll set it + // Store the task's mode and API config name when it's created. + // For history items, use the stored values; for new tasks, we'll set them // after getting state. if (historyItem) { this._taskMode = historyItem.mode || defaultModeSlug + this._taskApiConfigName = historyItem.apiConfigName this.taskModeReady = Promise.resolve() + this.taskApiConfigReady = Promise.resolve() TelemetryService.instance.captureTaskRestarted(this.taskId) // For history items, use the persisted tool protocol if available. // If not available (old tasks), it will be detected in resumeTaskFromHistory. this._taskToolProtocol = historyItem.toolProtocol } else { - // For new tasks, don't set the mode yet - wait for async initialization. + // For new tasks, don't set the mode/apiConfigName yet - wait for async initialization. this._taskMode = undefined + this._taskApiConfigName = undefined this.taskModeReady = this.initializeTaskMode(provider) + this.taskApiConfigReady = this.initializeTaskApiConfigName(provider) TelemetryService.instance.captureTaskCreated(this.taskId) // For new tasks, resolve and lock the tool protocol immediately. @@ -616,6 +686,47 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Initialize the task API config name from the provider state. + * This method handles async initialization with proper error handling. + * + * ## Flow + * 1. Attempts to fetch the current API config name from provider state + * 2. Sets `_taskApiConfigName` to the fetched name or "default" if unavailable + * 3. Handles errors gracefully by falling back to "default" + * 4. Logs any initialization errors for debugging + * + * ## Error handling + * - Network failures when fetching provider state + * - Provider not yet initialized + * - Invalid state structure + * + * All errors result in fallback to "default" to ensure task can proceed. + * + * @private + * @param provider - The ClineProvider instance to fetch state from + * @returns Promise that resolves when initialization is complete + */ + private async initializeTaskApiConfigName(provider: ClineProvider): Promise { + try { + const state = await provider.getState() + + // Avoid clobbering a newer value that may have been set while awaiting provider state + // (e.g., user switches provider profile immediately after task creation). + if (this._taskApiConfigName === undefined) { + this._taskApiConfigName = state?.currentApiConfigName ?? "default" + } + } catch (error) { + // If there's an error getting state, use the default profile (unless a newer value was set). + if (this._taskApiConfigName === undefined) { + this._taskApiConfigName = "default" + } + // Use the provider's log method for better error visibility + const errorMessage = `Failed to initialize task API config name: ${error instanceof Error ? error.message : String(error)}` + provider.log(errorMessage) + } + } + /** * Sets up a listener for provider profile changes to automatically update the parser state. * This ensures the XML/native protocol parser stays synchronized with the current model. @@ -736,6 +847,73 @@ export class Task extends EventEmitter implements TaskLike { return this._taskMode } + /** + * Wait for the task API config name to be initialized before proceeding. + * This method ensures that any operations depending on the task's provider profile + * will have access to the correct value. + * + * ## When to use + * - Before accessing provider profile-specific configurations + * - When switching between tasks with different provider profiles + * - Before operations that depend on the provider profile + * + * @returns Promise that resolves when the task API config name is initialized + * @public + */ + public async waitForApiConfigInitialization(): Promise { + return this.taskApiConfigReady + } + + /** + * Get the task API config name asynchronously, ensuring it's properly initialized. + * This is the recommended way to access the task's provider profile as it guarantees + * the value is available before returning. + * + * ## Async behavior + * - Internally waits for `taskApiConfigReady` promise to resolve + * - Returns the initialized API config name or undefined as fallback + * - Safe to call multiple times - subsequent calls return immediately if already initialized + * + * @returns Promise resolving to the task API config name string or undefined + * @public + */ + public async getTaskApiConfigName(): Promise { + await this.taskApiConfigReady + return this._taskApiConfigName + } + + /** + * Get the task API config name synchronously. This should only be used when you're certain + * that the value has already been initialized (e.g., after waitForApiConfigInitialization). + * + * ## When to use + * - In synchronous contexts where async/await is not available + * - After explicitly waiting for initialization via `waitForApiConfigInitialization()` + * - In event handlers or callbacks where API config name is guaranteed to be initialized + * + * Note: Unlike taskMode, this getter does not throw if uninitialized since the API config + * name can legitimately be undefined (backward compatibility with tasks created before + * this feature was added). + * + * @returns The task API config name string or undefined + * @public + */ + public get taskApiConfigName(): string | undefined { + return this._taskApiConfigName + } + + /** + * Update the task's API config name. This is called when the user switches + * provider profiles while a task is active, allowing the task to remember + * its new provider profile. + * + * @param apiConfigName - The new API config name to set + * @internal + */ + public setTaskApiConfigName(apiConfigName: string | undefined): void { + this._taskApiConfigName = apiConfigName + } + static create(options: TaskOptions): [Task, Promise] { const instance = new Task({ ...options, startTask: false }) const { images, task, historyItem } = options @@ -1004,6 +1182,10 @@ export class Task extends EventEmitter implements TaskLike { globalStoragePath: this.globalStoragePath, }) + if (this._taskApiConfigName === undefined) { + await this.taskApiConfigReady + } + const { historyItem, tokenUsage } = await taskMetadata({ taskId: this.taskId, rootTaskId: this.rootTaskId, @@ -1013,6 +1195,7 @@ export class Task extends EventEmitter implements TaskLike { globalStoragePath: this.globalStoragePath, workspace: this.cwd, mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. + apiConfigName: this._taskApiConfigName, // Use the task's own provider profile, not the current provider profile. initialStatus: this.initialStatus, toolProtocol: this._taskToolProtocol, // Persist the locked tool protocol. }) @@ -1089,7 +1272,6 @@ export class Task extends EventEmitter implements TaskLike { // state. askTs = Date.now() this.lastMessageTs = askTs - console.log(`Task#ask: new partial ask -> ${type} @ ${askTs}`) await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial, isProtected }) // console.log("Task#ask: current ask promise was ignored (#2)") throw new AskIgnoredError("new partial") @@ -1114,7 +1296,6 @@ export class Task extends EventEmitter implements TaskLike { // So in this case we must make sure that the message ts is // never altered after first setting it. askTs = lastMessage.ts - console.log(`Task#ask: updating previous partial ask -> ${type} @ ${askTs}`) this.lastMessageTs = askTs lastMessage.text = text lastMessage.partial = false @@ -1128,7 +1309,6 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseText = undefined this.askResponseImages = undefined askTs = Date.now() - console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`) this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) } @@ -1139,7 +1319,6 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseText = undefined this.askResponseImages = undefined askTs = Date.now() - console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`) this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) } @@ -1169,15 +1348,9 @@ export class Task extends EventEmitter implements TaskLike { // block (via the `pWaitFor`). const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs) const isMessageQueued = !this.messageQueueService.isEmpty() - const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask" - if (isBlocking) { - console.log(`Task#ask will block -> type: ${type}`) - } - if (isStatusMutable) { - console.log(`Task#ask: status is mutable -> type: ${type}`) const statusMutationTimeout = 2_000 if (isInteractiveAsk(type)) { @@ -1216,8 +1389,6 @@ export class Task extends EventEmitter implements TaskLike { ) } } else if (isMessageQueued) { - console.log(`Task#ask: will process message queue -> type: ${type}`) - const message = this.messageQueueService.dequeueMessage() if (message) { @@ -1240,13 +1411,42 @@ export class Task extends EventEmitter implements TaskLike { } // Wait for askResponse to be set - await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) + await pWaitFor( + () => { + if (this.askResponse !== undefined || this.lastMessageTs !== askTs) { + return true + } + + // If a queued message arrives while we're blocked on an ask (e.g. a follow-up + // suggestion click that was incorrectly queued due to UI state), consume it + // immediately so the task doesn't hang. + if (!this.messageQueueService.isEmpty()) { + const message = this.messageQueueService.dequeueMessage() + if (message) { + // If this is a tool approval ask, we need to approve first (yesButtonClicked) + // and include any queued text/images. + if ( + type === "tool" || + type === "command" || + type === "browser_action_launch" || + type === "use_mcp_server" + ) { + this.handleWebviewAskResponse("yesButtonClicked", message.text, message.images) + } else { + this.handleWebviewAskResponse("messageResponse", message.text, message.images) + } + } + } + + return false + }, + { interval: 100 }, + ) if (this.lastMessageTs !== askTs) { // Could happen if we send multiple asks in a row i.e. with // command_output. It's important that when we know an ask could // fail, it is handled gracefully. - console.log("Task#ask: current ask promise was ignored") throw new AskIgnoredError("superseded") } @@ -1323,6 +1523,10 @@ export class Task extends EventEmitter implements TaskLike { this.handleWebviewAskResponse("noButtonClicked", text, images) } + public supersedePendingAsk(): void { + this.lastMessageTs = Date.now() + } + /** * Updates the API configuration but preserves the locked tool protocol. * The task's tool protocol is locked at creation time and should NOT change @@ -1393,6 +1597,10 @@ export class Task extends EventEmitter implements TaskLike { } public async condenseContext(): Promise { + // CRITICAL: Flush any pending tool results before condensing + // to ensure tool_use/tool_result pairs are complete in history + await this.flushPendingToolResultsToHistory() + const systemPrompt = await this.getSystemPrompt() // Get condensing configuration @@ -1985,6 +2193,7 @@ export class Task extends EventEmitter implements TaskLike { // Reset consecutive error counters on abort (manual intervention) this.consecutiveNoToolUseCount = 0 + this.consecutiveNoAssistantMessagesCount = 0 // Force final token usage update before abort event this.emitFinalTokenUsageUpdate() @@ -2311,6 +2520,17 @@ export class Task extends EventEmitter implements TaskLike { const modelId = getModelId(this.apiConfiguration) const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId) + // Respect user-configured provider rate limiting BEFORE we emit api_req_started. + // This prevents the UI from showing an "API Request..." spinner while we are + // intentionally waiting due to the rate limit slider. + // + // NOTE: We also set Task.lastGlobalApiRequestTime here to reserve this slot + // before we build environment details (which can take time). + // This ensures subsequent requests (including subtasks) still honour the + // provider rate-limit window. + await this.maybeWaitForProviderRateLimit(currentItem.retryAttempt ?? 0) + Task.lastGlobalApiRequestTime = performance.now() + await this.say( "api_req_started", JSON.stringify({ @@ -2325,7 +2545,7 @@ export class Task extends EventEmitter implements TaskLike { maxReadFileLine = -1, } = (await this.providerRef.deref()?.getState()) ?? {} - const parsedUserContent = await processUserContentMentions({ + const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({ userContent: currentUserContent, cwd: this.cwd, urlContentFetcher: this.urlContentFetcher, @@ -2337,6 +2557,18 @@ export class Task extends EventEmitter implements TaskLike { maxReadFileLine, }) + // Switch mode if specified in a slash command's frontmatter + if (slashCommandMode) { + const provider = this.providerRef.deref() + if (provider) { + const state = await provider.getState() + const targetMode = getModeBySlug(slashCommandMode, state?.customModes) + if (targetMode) { + await provider.handleModeSwitch(slashCommandMode) + } + } + } + const environmentDetails = await getEnvironmentDetails(this, currentIncludeFileDetails) // Remove any existing environment_details blocks before adding fresh ones. @@ -2452,7 +2684,6 @@ export class Task extends EventEmitter implements TaskLike { // lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list lastMessage.partial = false // instead of streaming partialMessage events, we do a save and post like normal to persist to disk - console.log("updating partial message", lastMessage) } // Update `api_req_started` to have cancelled and cost, so that @@ -2506,7 +2737,7 @@ export class Task extends EventEmitter implements TaskLike { // Yields only if the first chunk is successful, otherwise will // allow the user to retry the request (most likely due to rate // limit error, which gets thrown on the first chunk). - const stream = this.attemptApiRequest() + const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { skipProviderRateLimit: true }) let assistantMessage = "" let reasoningMessage = "" let pendingGroundingSources: GroundingSource[] = [] @@ -3158,6 +3389,8 @@ export class Task extends EventEmitter implements TaskLike { ) if (hasTextContent || hasToolUses) { + // Reset counter when we get a successful response with content + this.consecutiveNoAssistantMessagesCount = 0 // Display grounding sources to the user if they exist if (pendingGroundingSources.length > 0) { const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`) @@ -3291,6 +3524,15 @@ export class Task extends EventEmitter implements TaskLike { // or tool_use content blocks from API which we should assume is // an error. + // Increment consecutive no-assistant-messages counter + this.consecutiveNoAssistantMessagesCount++ + + // Only show error and count toward mistake limit after 2 consecutive failures + // This provides a "grace retry" - first failure retries silently + if (this.consecutiveNoAssistantMessagesCount >= 2) { + await this.say("error", "MODEL_NO_ASSISTANT_MESSAGES") + } + // IMPORTANT: For native tool protocol, we already added the user message to // apiConversationHistory at line 1876. Since the assistant failed to respond, // we need to remove that message before retrying to avoid having two consecutive @@ -3435,6 +3677,7 @@ export class Task extends EventEmitter implements TaskLike { maxConcurrentFileReads, maxReadFileLine, apiConfiguration, + enableSubfolderRules, } = state ?? {} return await (async () => { @@ -3487,6 +3730,7 @@ export class Task extends EventEmitter implements TaskLike { browserToolEnabled: browserToolEnabled ?? true, useAgentRules: vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, + enableSubfolderRules: enableSubfolderRules ?? false, newTaskRequireTodos: vscode.workspace .getConfiguration(Package.name) .get("newTaskRequireTodos", false), @@ -3495,6 +3739,7 @@ export class Task extends EventEmitter implements TaskLike { }, undefined, // todoList this.api.getModel().id, + provider.getSkillsManager(), ) })() } @@ -3596,7 +3841,44 @@ export class Task extends EventEmitter implements TaskLike { await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId }) } - public async *attemptApiRequest(retryAttempt: number = 0): ApiStream { + /** + * Enforce the user-configured provider rate limit. + * + * NOTE: This is intentionally treated as expected behavior and is surfaced via + * the `api_req_rate_limit_wait` say type (not an error). + */ + private async maybeWaitForProviderRateLimit(retryAttempt: number): Promise { + const state = await this.providerRef.deref()?.getState() + const rateLimitSeconds = + state?.apiConfiguration?.rateLimitSeconds ?? this.apiConfiguration?.rateLimitSeconds ?? 0 + + if (rateLimitSeconds <= 0 || !Task.lastGlobalApiRequestTime) { + return + } + + const now = performance.now() + const timeSinceLastRequest = now - Task.lastGlobalApiRequestTime + const rateLimitDelay = Math.ceil( + Math.min(rateLimitSeconds, Math.max(0, rateLimitSeconds * 1000 - timeSinceLastRequest) / 1000), + ) + + // Only show the countdown UX on the first attempt. Retry flows have their own delay messaging. + if (rateLimitDelay > 0 && retryAttempt === 0) { + for (let i = rateLimitDelay; i > 0; i--) { + // Send structured JSON data for i18n-safe transport + const delayMessage = JSON.stringify({ seconds: i }) + await this.say("api_req_rate_limit_wait", delayMessage, undefined, true) + await delay(1000) + } + // Finalize the partial message so the UI doesn't keep rendering an in-progress spinner. + await this.say("api_req_rate_limit_wait", undefined, undefined, false) + } + } + + public async *attemptApiRequest( + retryAttempt: number = 0, + options: { skipProviderRateLimit?: boolean } = {}, + ): ApiStream { const state = await this.providerRef.deref()?.getState() const { @@ -3633,29 +3915,17 @@ export class Task extends EventEmitter implements TaskLike { } } - let rateLimitDelay = 0 - - // Use the shared timestamp so that subtasks respect the same rate-limit - // window as their parent tasks. - if (Task.lastGlobalApiRequestTime) { - const now = performance.now() - const timeSinceLastRequest = now - Task.lastGlobalApiRequestTime - const rateLimit = apiConfiguration?.rateLimitSeconds || 0 - rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - timeSinceLastRequest) / 1000)) + if (!options.skipProviderRateLimit) { + await this.maybeWaitForProviderRateLimit(retryAttempt) } - // Only show rate limiting message if we're not retrying. If retrying, we'll include the delay there. - if (rateLimitDelay > 0 && retryAttempt === 0) { - // Show countdown timer - for (let i = rateLimitDelay; i > 0; i--) { - const delayMessage = `Rate limiting for ${i} seconds...` - await this.say("api_req_retry_delayed", delayMessage, undefined, true) - await delay(1000) - } - } - - // Update last request time before making the request so that subsequent + // Update last request time right before making the request so that subsequent // requests — even from new subtasks — will honour the provider's rate-limit. + // + // NOTE: When recursivelyMakeClineRequests handles rate limiting, it sets the + // timestamp earlier to include the environment details build. We still set it + // here for direct callers (tests) and for the case where we didn't rate-limit + // in the caller. Task.lastGlobalApiRequestTime = performance.now() const systemPrompt = await this.getSystemPrompt() @@ -3826,6 +4096,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, maxReadFileLine: state?.maxReadFileLine ?? -1, + maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5, browserToolEnabled: state?.browserToolEnabled ?? true, modelInfo, diffEnabled: this.diffEnabled, @@ -3971,7 +4242,7 @@ export class Task extends EventEmitter implements TaskLike { // Respect provider rate limit window let rateLimitDelay = 0 - const rateLimit = state?.apiConfiguration?.rateLimitSeconds || 0 + const rateLimit = (state?.apiConfiguration ?? this.apiConfiguration)?.rateLimitSeconds || 0 if (Task.lastGlobalApiRequestTime && rateLimit > 0) { const elapsed = performance.now() - Task.lastGlobalApiRequestTime rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - elapsed) / 1000)) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 7b2d0f3a36..5b7346d49d 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -132,7 +132,7 @@ vi.mock("vscode", () => { vi.mock("../../mentions", () => ({ parseMentions: vi.fn().mockImplementation((text) => { - return Promise.resolve(`processed: ${text}`) + return Promise.resolve({ text: `processed: ${text}`, mode: undefined }) }), openMention: vi.fn(), getLatestTerminalOutput: vi.fn(), @@ -913,7 +913,7 @@ describe("Cline", () => { } as Anthropic.ToolResultBlockParam, ] - const processedContent = await processUserContentMentions({ + const { content: processedContent } = await processUserContentMentions({ userContent, cwd: cline.cwd, urlContentFetcher: cline.urlContentFetcher, @@ -976,6 +976,7 @@ describe("Cline", () => { apiConfiguration: mockApiConfig, }), getMcpHub: vi.fn().mockReturnValue(undefined), + getSkillsManager: vi.fn().mockReturnValue(undefined), say: vi.fn(), postStateToWebview: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), @@ -1040,6 +1041,9 @@ describe("Cline", () => { startTask: false, }) + // Spy on child.say to verify the emitted message type + const saySpy = vi.spyOn(child, "say") + // Mock the child's API stream const childMockStream = { async *[Symbol.asyncIterator]() { @@ -1066,6 +1070,17 @@ describe("Cline", () => { // Verify rate limiting was applied expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds) expect(mockDelay).toHaveBeenCalledWith(1000) + + // Verify we used the non-error rate-limit wait message type (JSON format) + expect(saySpy).toHaveBeenCalledWith( + "api_req_rate_limit_wait", + expect.stringMatching(/\{"seconds":\d+\}/), + undefined, + true, + ) + + // Verify the wait message was finalized + expect(saySpy).toHaveBeenCalledWith("api_req_rate_limit_wait", undefined, undefined, false) }, 10000) // Increase timeout to 10 seconds it("should not apply rate limiting if enough time has passed", async () => { @@ -1962,3 +1977,205 @@ describe("Queued message processing after condense", () => { expect(taskB.messageQueueService.isEmpty()).toBe(true) }) }) + +describe("pushToolResultToUserContent", () => { + let mockProvider: any + let mockApiConfig: ProviderSettings + + beforeEach(() => { + mockApiConfig = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } + + const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") } + const mockExtensionContext = { + globalState: { + get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + globalStorageUri: storageUri, + workspaceState: { + get: vi.fn().mockImplementation((_key) => undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }, + extensionUri: { fsPath: "/mock/extension/path" }, + extension: { packageJSON: { version: "1.0.0" } }, + } as unknown as vscode.ExtensionContext + + const mockOutputChannel = { + name: "test-output", + appendLine: vi.fn(), + append: vi.fn(), + replace: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + } + + mockProvider = new ClineProvider( + mockExtensionContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockExtensionContext), + ) as any + + mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + }) + + it("should add tool_result when not a duplicate", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const toolResult: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "test-id-1", + content: "Test result", + } + + const added = task.pushToolResultToUserContent(toolResult) + + expect(added).toBe(true) + expect(task.userMessageContent).toHaveLength(1) + expect(task.userMessageContent[0]).toEqual(toolResult) + }) + + it("should prevent duplicate tool_result with same tool_use_id", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const toolResult1: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "duplicate-id", + content: "First result", + } + + const toolResult2: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "duplicate-id", + content: "Second result (should be skipped)", + } + + // Spy on console.warn to verify warning is logged + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + // Add first result - should succeed + const added1 = task.pushToolResultToUserContent(toolResult1) + expect(added1).toBe(true) + expect(task.userMessageContent).toHaveLength(1) + + // Add second result with same ID - should be skipped + const added2 = task.pushToolResultToUserContent(toolResult2) + expect(added2).toBe(false) + expect(task.userMessageContent).toHaveLength(1) + + // Verify only the first result is in the array + expect(task.userMessageContent[0]).toEqual(toolResult1) + + // Verify warning was logged + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Skipping duplicate tool_result for tool_use_id: duplicate-id"), + ) + + warnSpy.mockRestore() + }) + + it("should allow different tool_use_ids to be added", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const toolResult1: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "id-1", + content: "Result 1", + } + + const toolResult2: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "id-2", + content: "Result 2", + } + + const added1 = task.pushToolResultToUserContent(toolResult1) + const added2 = task.pushToolResultToUserContent(toolResult2) + + expect(added1).toBe(true) + expect(added2).toBe(true) + expect(task.userMessageContent).toHaveLength(2) + expect(task.userMessageContent[0]).toEqual(toolResult1) + expect(task.userMessageContent[1]).toEqual(toolResult2) + }) + + it("should handle tool_result with is_error flag", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const errorResult: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "error-id", + content: "Error message", + is_error: true, + } + + const added = task.pushToolResultToUserContent(errorResult) + + expect(added).toBe(true) + expect(task.userMessageContent).toHaveLength(1) + expect(task.userMessageContent[0]).toEqual(errorResult) + }) + + it("should not interfere with other content types in userMessageContent", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Add text and image blocks manually + task.userMessageContent.push( + { type: "text", text: "Some text" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } }, + ) + + const toolResult: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "test-id", + content: "Result", + } + + const added = task.pushToolResultToUserContent(toolResult) + + expect(added).toBe(true) + expect(task.userMessageContent).toHaveLength(3) + expect(task.userMessageContent[0].type).toBe("text") + expect(task.userMessageContent[1].type).toBe("image") + expect(task.userMessageContent[2]).toEqual(toolResult) + }) +}) diff --git a/src/core/task/__tests__/Task.sticky-profile-race.spec.ts b/src/core/task/__tests__/Task.sticky-profile-race.spec.ts new file mode 100644 index 0000000000..e78301541d --- /dev/null +++ b/src/core/task/__tests__/Task.sticky-profile-race.spec.ts @@ -0,0 +1,142 @@ +// npx vitest run core/task/__tests__/Task.sticky-profile-race.spec.ts + +import * as vscode from "vscode" + +import type { ProviderSettings } from "@roo-code/types" +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + hasInstance: vi.fn().mockReturnValue(true), + createInstance: vi.fn(), + get instance() { + return { + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + captureModeSwitch: vi.fn(), + captureConversationMessage: vi.fn(), + captureLlmCompletion: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + captureCodeActionUsed: vi.fn(), + setProvider: vi.fn(), + } + }, + }, +})) + +vi.mock("vscode", () => { + const mockDisposable = { dispose: vi.fn() } + const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } + const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } + const mockTextEditor = { document: mockTextDocument } + const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } } + const mockTabGroup = { tabs: [mockTab] } + + return { + TabInputTextDiff: vi.fn(), + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + window: { + createTextEditorDecorationType: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), + visibleTextEditors: [mockTextEditor], + tabGroups: { + all: [mockTabGroup], + close: vi.fn(), + onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })), + }, + showErrorMessage: vi.fn(), + }, + workspace: { + getConfiguration: vi.fn(() => ({ get: (_k: string, d: any) => d })), + workspaceFolders: [ + { + uri: { fsPath: "/mock/workspace/path" }, + name: "mock-workspace", + index: 0, + }, + ], + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(() => mockDisposable), + onDidDelete: vi.fn(() => mockDisposable), + onDidChange: vi.fn(() => mockDisposable), + dispose: vi.fn(), + })), + fs: { + stat: vi.fn().mockResolvedValue({ type: 1 }), + }, + onDidSaveTextDocument: vi.fn(() => mockDisposable), + }, + env: { + uriScheme: "vscode", + language: "en", + }, + EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter), + Disposable: { + from: vi.fn(), + }, + TabInputText: vi.fn(), + version: "1.85.0", + } +}) + +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../ignore/RooIgnoreController") + +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockImplementation(async () => Promise.resolve()), +})) + +vi.mock("delay", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +describe("Task - sticky provider profile init race", () => { + it("does not overwrite task apiConfigName if set during async initialization", async () => { + const apiConfig: ProviderSettings = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } as any + + let resolveGetState: ((v: any) => void) | undefined + const getStatePromise = new Promise((resolve) => { + resolveGetState = resolve + }) + + const mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/storage" }, + }, + getState: vi.fn().mockImplementation(() => getStatePromise), + log: vi.fn(), + on: vi.fn(), + off: vi.fn(), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + } as unknown as ClineProvider + + const task = new Task({ + provider: mockProvider, + apiConfiguration: apiConfig, + task: "test task", + startTask: false, + }) + + // Simulate a profile switch happening before provider.getState resolves. + task.setTaskApiConfigName("new-profile") + + resolveGetState?.({ currentApiConfigName: "old-profile" }) + await task.waitForApiConfigInitialization() + + expect(task.taskApiConfigName).toBe("new-profile") + }) +}) diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts new file mode 100644 index 0000000000..3b4097a940 --- /dev/null +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -0,0 +1,38 @@ +import { Task } from "../Task" + +// Keep this test focused: if a queued message arrives while Task.ask() is blocked, +// it should be consumed and used to fulfill the ask. + +describe("Task.ask queued message drain", () => { + it("consumes queued message while blocked on followup ask", async () => { + const task = Object.create(Task.prototype) as Task + ;(task as any).abort = false + ;(task as any).clineMessages = [] + ;(task as any).askResponse = undefined + ;(task as any).askResponseText = undefined + ;(task as any).askResponseImages = undefined + ;(task as any).lastMessageTs = undefined + + // Message queue service exists in constructor; for unit test we can attach a real one. + const { MessageQueueService } = await import("../../message-queue/MessageQueueService") + ;(task as any).messageQueueService = new MessageQueueService() + + // Minimal stubs used by ask() + ;(task as any).addToClineMessages = vi.fn(async () => {}) + ;(task as any).saveClineMessages = vi.fn(async () => {}) + ;(task as any).updateClineMessage = vi.fn(async () => {}) + ;(task as any).cancelAutoApprovalTimeout = vi.fn(() => {}) + ;(task as any).checkpointSave = vi.fn(async () => {}) + ;(task as any).emit = vi.fn() + ;(task as any).providerRef = { deref: () => undefined } + + const askPromise = task.ask("followup", "Q?", false) + + // Simulate webview queuing the user's selection text while the ask is pending. + ;(task as any).messageQueueService.addMessage("picked answer") + + const result = await askPromise + expect(result.response).toBe("messageResponse") + expect(result.text).toBe("picked answer") + }) +}) diff --git a/src/core/task/__tests__/grace-retry-errors.spec.ts b/src/core/task/__tests__/grace-retry-errors.spec.ts new file mode 100644 index 0000000000..5ea0e1ddb3 --- /dev/null +++ b/src/core/task/__tests__/grace-retry-errors.spec.ts @@ -0,0 +1,443 @@ +// npx vitest core/task/__tests__/grace-retry-errors.spec.ts + +import * as os from "os" +import * as path from "path" +import * as vscode from "vscode" + +import type { GlobalState, ProviderSettings } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" + +// Mock @roo-code/core +vi.mock("@roo-code/core", () => ({ + customToolRegistry: { + getTools: vi.fn().mockReturnValue([]), + hasTool: vi.fn().mockReturnValue(false), + getTool: vi.fn().mockReturnValue(undefined), + }, +})) + +// Mock delay before any imports that might use it +vi.mock("delay", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +vi.mock("fs/promises", async (importOriginal) => { + const actual = (await importOriginal()) as Record + const mockFunctions = { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockImplementation(() => Promise.resolve("[]")), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + } + + return { + ...actual, + ...mockFunctions, + default: mockFunctions, + } +}) + +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockImplementation(async () => Promise.resolve()), +})) + +vi.mock("vscode", () => { + const mockDisposable = { dispose: vi.fn() } + const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } + const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } + const mockTextEditor = { document: mockTextDocument } + const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } } + const mockTabGroup = { tabs: [mockTab] } + + return { + TabInputTextDiff: vi.fn(), + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + window: { + createTextEditorDecorationType: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), + visibleTextEditors: [mockTextEditor], + tabGroups: { + all: [mockTabGroup], + close: vi.fn(), + onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })), + }, + showErrorMessage: vi.fn(), + }, + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/mock/workspace/path" }, + name: "mock-workspace", + index: 0, + }, + ], + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(() => mockDisposable), + onDidDelete: vi.fn(() => mockDisposable), + onDidChange: vi.fn(() => mockDisposable), + dispose: vi.fn(), + })), + fs: { + stat: vi.fn().mockResolvedValue({ type: 1 }), + }, + onDidSaveTextDocument: vi.fn(() => mockDisposable), + getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })), + }, + env: { + uriScheme: "vscode", + language: "en", + }, + EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter), + Disposable: { + from: vi.fn(), + }, + TabInputText: vi.fn(), + } +}) + +vi.mock("../../mentions", () => ({ + parseMentions: vi.fn().mockImplementation((text) => { + return Promise.resolve(`processed: ${text}`) + }), + openMention: vi.fn(), + getLatestTerminalOutput: vi.fn(), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../ignore/RooIgnoreController") + +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), + getSettingsDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockImplementation(() => false), +})) + +describe("Grace Retry Error Handling", () => { + let mockProvider: any + let mockApiConfig: ProviderSettings + let mockOutputChannel: any + let mockExtensionContext: vscode.ExtensionContext + + beforeEach(() => { + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const storageUri = { + fsPath: path.join(os.tmpdir(), "test-storage"), + } + + mockExtensionContext = { + globalState: { + get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + globalStorageUri: storageUri, + workspaceState: { + get: vi.fn().mockImplementation((_key) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockImplementation((_key) => Promise.resolve(undefined)), + store: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + delete: vi.fn().mockImplementation((_key) => Promise.resolve()), + }, + extensionUri: { + fsPath: "/mock/extension/path", + }, + extension: { + packageJSON: { + version: "1.0.0", + }, + }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + } + + mockProvider = new ClineProvider( + mockExtensionContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockExtensionContext), + ) as any + + mockApiConfig = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } + + mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.getState = vi.fn().mockResolvedValue({}) + }) + + describe("consecutiveNoAssistantMessagesCount", () => { + it("should initialize to 0", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + }) + + it("should reset to 0 when abortTask is called", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Manually set the counter to simulate consecutive failures + task.consecutiveNoAssistantMessagesCount = 5 + + // Mock dispose to prevent actual cleanup + vi.spyOn(task, "dispose").mockImplementation(() => {}) + + await task.abortTask() + + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + }) + + it("should reset consecutiveNoToolUseCount when abortTask is called", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Manually set both counters + task.consecutiveNoAssistantMessagesCount = 3 + task.consecutiveNoToolUseCount = 4 + + // Mock dispose to prevent actual cleanup + vi.spyOn(task, "dispose").mockImplementation(() => {}) + + await task.abortTask() + + // Both counters should be reset + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + expect(task.consecutiveNoToolUseCount).toBe(0) + }) + }) + + describe("consecutiveNoToolUseCount", () => { + it("should initialize to 0", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + expect(task.consecutiveNoToolUseCount).toBe(0) + }) + }) + + describe("Grace Retry Pattern", () => { + it("should not show error on first failure (grace retry)", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + + // Simulate first empty response - should NOT show error + task.consecutiveNoAssistantMessagesCount = 0 + task.consecutiveNoAssistantMessagesCount++ + expect(task.consecutiveNoAssistantMessagesCount).toBe(1) + + // First failure: grace retry (silent) + if (task.consecutiveNoAssistantMessagesCount >= 2) { + await task.say("error", "MODEL_NO_ASSISTANT_MESSAGES") + } + + // Verify error was NOT called (grace retry on first failure) + expect(saySpy).not.toHaveBeenCalledWith("error", "MODEL_NO_ASSISTANT_MESSAGES") + }) + + it("should show error after 2 consecutive failures", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + + // Simulate second consecutive empty response + task.consecutiveNoAssistantMessagesCount = 1 + task.consecutiveNoAssistantMessagesCount++ + expect(task.consecutiveNoAssistantMessagesCount).toBe(2) + + // Second failure: should show error + if (task.consecutiveNoAssistantMessagesCount >= 2) { + await task.say("error", "MODEL_NO_ASSISTANT_MESSAGES") + } + + // Verify error was called (after 2 consecutive failures) + expect(saySpy).toHaveBeenCalledWith("error", "MODEL_NO_ASSISTANT_MESSAGES") + }) + + it("should show error on third consecutive failure", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + + // Simulate third consecutive empty response + task.consecutiveNoAssistantMessagesCount = 2 + task.consecutiveNoAssistantMessagesCount++ + expect(task.consecutiveNoAssistantMessagesCount).toBe(3) + + // Third failure: should also show error + if (task.consecutiveNoAssistantMessagesCount >= 2) { + await task.say("error", "MODEL_NO_ASSISTANT_MESSAGES") + } + + // Verify error was called + expect(saySpy).toHaveBeenCalledWith("error", "MODEL_NO_ASSISTANT_MESSAGES") + }) + }) + + describe("Counter Reset on Success", () => { + it("should be able to simulate counter reset when valid content is received", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Simulate some consecutive failures + task.consecutiveNoAssistantMessagesCount = 3 + + // Simulate receiving valid content + const hasTextContent = true + const hasToolUses = false + + if (hasTextContent || hasToolUses) { + task.consecutiveNoAssistantMessagesCount = 0 + } + + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + }) + + it("should reset counter when tool uses are present", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Simulate some consecutive failures + task.consecutiveNoAssistantMessagesCount = 2 + + // Simulate receiving tool uses + const hasTextContent = false + const hasToolUses = true + + if (hasTextContent || hasToolUses) { + task.consecutiveNoAssistantMessagesCount = 0 + } + + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + }) + }) + + describe("Error Marker", () => { + it("should use MODEL_NO_ASSISTANT_MESSAGES marker for error display", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + + // Simulate the error condition (2 consecutive failures) + task.consecutiveNoAssistantMessagesCount = 2 + + if (task.consecutiveNoAssistantMessagesCount >= 2) { + await task.say("error", "MODEL_NO_ASSISTANT_MESSAGES") + } + + // Verify the exact marker is used + expect(saySpy).toHaveBeenCalledWith("error", "MODEL_NO_ASSISTANT_MESSAGES") + }) + }) + + describe("Parallel with noToolsUsed error handling", () => { + it("should have separate counters for noToolsUsed and noAssistantMessages", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Both counters should start at 0 + expect(task.consecutiveNoToolUseCount).toBe(0) + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + + // Incrementing one should not affect the other + task.consecutiveNoToolUseCount = 3 + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + + task.consecutiveNoAssistantMessagesCount = 2 + expect(task.consecutiveNoToolUseCount).toBe(3) + }) + }) +}) diff --git a/src/core/task/__tests__/validateToolResultIds.spec.ts b/src/core/task/__tests__/validateToolResultIds.spec.ts index 28491aedd7..0926e899aa 100644 --- a/src/core/task/__tests__/validateToolResultIds.spec.ts +++ b/src/core/task/__tests__/validateToolResultIds.spec.ts @@ -482,6 +482,96 @@ describe("validateAndFixToolResultIds", () => { expect(resultContent[1].type).toBe("text") expect((resultContent[1] as Anthropic.TextBlockParam).text).toBe("Some additional context") }) + + // Verifies fix for GitHub #10465: Terminal fallback race condition can generate + // duplicate tool_results with the same valid tool_use_id, causing API protocol violations. + it("should filter out duplicate tool_results with identical valid tool_use_ids (terminal fallback scenario)", () => { + const assistantMessage: Anthropic.MessageParam = { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g", + name: "execute_command", + input: { command: "ps aux | grep test", cwd: "/path/to/project" }, + }, + ], + } + + // Two tool_results with the SAME valid tool_use_id from terminal fallback race condition + const userMessage: Anthropic.MessageParam = { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g", // First result from command execution + content: "No test processes found", + }, + { + type: "tool_result", + tool_use_id: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g", // Duplicate from user approval during fallback + content: '{"status":"approved","message":"The user approved this operation"}', + }, + ], + } + + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) + + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Anthropic.ToolResultBlockParam[] + + // Only ONE tool_result should remain to prevent API protocol violation + expect(resultContent.length).toBe(1) + expect(resultContent[0].tool_use_id).toBe("tooluse_QZ-pU8v2QKO8L8fHoJRI2g") + expect(resultContent[0].content).toBe("No test processes found") + }) + + it("should preserve text blocks while deduplicating tool_results with same valid ID", () => { + const assistantMessage: Anthropic.MessageParam = { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.txt" }, + }, + ], + } + + const userMessage: Anthropic.MessageParam = { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "First result", + }, + { + type: "text", + text: "Environment details here", + }, + { + type: "tool_result", + tool_use_id: "tool-123", // Duplicate with same valid ID + content: "Duplicate result from fallback", + }, + ], + } + + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) + + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Array + + // Should have: 1 tool_result + 1 text block (duplicate filtered out) + expect(resultContent.length).toBe(2) + expect(resultContent[0].type).toBe("tool_result") + expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-123") + expect((resultContent[0] as Anthropic.ToolResultBlockParam).content).toBe("First result") + expect(resultContent[1].type).toBe("text") + expect((resultContent[1] as Anthropic.TextBlockParam).text).toBe("Environment details here") + }) }) describe("when there are more tool_uses than tool_results", () => { diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index 8eea4ace82..52a9f2eb82 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -19,6 +19,7 @@ interface BuildToolsOptions { experiments: Record | undefined apiConfiguration: ProviderSettings | undefined maxReadFileLine: number + maxConcurrentFileReads: number browserToolEnabled: boolean modelInfo?: ModelInfo diffEnabled: boolean @@ -40,6 +41,7 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise experiments, apiConfiguration, maxReadFileLine, + maxConcurrentFileReads, browserToolEnabled, modelInfo, diffEnabled, @@ -62,8 +64,15 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise // Determine if partial reads are enabled based on maxReadFileLine setting. const partialReadsEnabled = maxReadFileLine !== -1 - // Build native tools with dynamic read_file tool based on partialReadsEnabled. - const nativeTools = getNativeTools(partialReadsEnabled) + // Check if the model supports images for read_file tool description. + const supportsImages = modelInfo?.supportsImages ?? false + + // Build native tools with dynamic read_file tool based on settings. + const nativeTools = getNativeTools({ + partialReadsEnabled, + maxConcurrentFileReads, + supportsImages, + }) // Filter native tools based on mode restrictions. const filteredNativeTools = filterNativeToolsForMode( diff --git a/src/core/task/validateToolResultIds.ts b/src/core/task/validateToolResultIds.ts index ce89a4e167..9dd73723a3 100644 --- a/src/core/task/validateToolResultIds.ts +++ b/src/core/task/validateToolResultIds.ts @@ -78,7 +78,31 @@ export function validateAndFixToolResultIds( } // Find tool_result blocks in the user message - const toolResults = userMessage.content.filter( + let toolResults = userMessage.content.filter( + (block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result", + ) + + // Deduplicate tool_result blocks to prevent API protocol violations (GitHub #10465) + // Terminal fallback race conditions can generate duplicate tool_results with the same tool_use_id. + // Filter out duplicates before validation since Set-based checks below would miss them. + const seenToolResultIds = new Set() + const deduplicatedContent = userMessage.content.filter((block) => { + if (block.type !== "tool_result") { + return true + } + if (seenToolResultIds.has(block.tool_use_id)) { + return false // Duplicate - filter out + } + seenToolResultIds.add(block.tool_use_id) + return true + }) + + userMessage = { + ...userMessage, + content: deduplicatedContent, + } + + toolResults = deduplicatedContent.filter( (block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result", ) @@ -139,15 +163,12 @@ export function validateAndFixToolResultIds( ) } - // Create a mapping of tool_result IDs to corrected IDs - // Strategy: Match by position (first tool_result -> first tool_use, etc.) - // This handles most cases where the mismatch is due to ID confusion - // - // Track which tool_use IDs have been used to prevent duplicates + // Match tool_results to tool_uses by position and fix incorrect IDs const usedToolUseIds = new Set() + const contentArray = userMessage.content as Anthropic.Messages.ContentBlockParam[] - const correctedContent = userMessage.content - .map((block) => { + const correctedContent = contentArray + .map((block: Anthropic.Messages.ContentBlockParam) => { if (block.type !== "tool_result") { return block } @@ -177,17 +198,18 @@ export function validateAndFixToolResultIds( } // No corresponding tool_use for this tool_result, or the ID is already used - // Filter out this orphaned tool_result by returning null return null }) .filter((block): block is NonNullable => block !== null) // Add missing tool_result blocks for any tool_use that doesn't have one - // After the ID correction above, recalculate which tool_use IDs are now covered const coveredToolUseIds = new Set( correctedContent - .filter((b): b is Anthropic.ToolResultBlockParam => b.type === "tool_result") - .map((r) => r.tool_use_id), + .filter( + (b: Anthropic.Messages.ContentBlockParam): b is Anthropic.ToolResultBlockParam => + b.type === "tool_result", + ) + .map((r: Anthropic.ToolResultBlockParam) => r.tool_use_id), ) const stillMissingToolUseIds = toolUseBlocks.filter((toolUse) => !coveredToolUseIds.has(toolUse.id)) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index f7271bffe9..7feb71b0b8 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -116,6 +116,9 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) await task.say("shell_integration_warning") + // Invalidate pending ask from first execution to prevent race condition + task.supersedePendingAsk() + if (error instanceof ShellIntegrationError) { const [rejected, result] = await executeCommandInTerminal(task, { ...options, diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 58f3cc495e..483d4f0025 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -1,4 +1,5 @@ import path from "path" +import * as fs from "fs/promises" import { isBinaryFile } from "isbinaryfile" import type { FileEntry, LineRange } from "@roo-code/types" import { isNativeProtocol, ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" @@ -123,6 +124,18 @@ export class ReadFileTool extends BaseTool<"read_file"> { return } + // Enforce maxConcurrentFileReads limit + const { maxConcurrentFileReads = 5 } = (await task.providerRef.deref()?.getState()) ?? {} + if (fileEntries.length > maxConcurrentFileReads) { + task.consecutiveMistakeCount++ + task.recordToolError("read_file") + const errorMsg = `Too many files requested. You attempted to read ${fileEntries.length} files, but the concurrent file reads limit is ${maxConcurrentFileReads}. Please read files in batches of ${maxConcurrentFileReads} or fewer.` + await task.say("error", errorMsg) + const errorResult = useNative ? `Error: ${errorMsg}` : `${errorMsg}` + pushToolResult(errorResult) + return + } + const supportsImages = modelInfo.supportsImages ?? false const fileResults: FileResult[] = fileEntries.map((entry) => ({ @@ -338,6 +351,20 @@ export class ReadFileTool extends BaseTool<"read_file"> { const fullPath = path.resolve(task.cwd, relPath) try { + // Check if the path is a directory before attempting to read it + const stats = await fs.stat(fullPath) + if (stats.isDirectory()) { + const errorMsg = `Cannot read '${relPath}' because it is a directory. To view the contents of a directory, use the list_files tool instead.` + updateFileResult(relPath, { + status: "error", + error: errorMsg, + xmlContent: `${relPath}Error reading file: ${errorMsg}`, + nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, + }) + await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + continue + } + const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) if (isBinary) { diff --git a/src/core/tools/RunSlashCommandTool.ts b/src/core/tools/RunSlashCommandTool.ts index 8af8bd6e12..69cb9dde95 100644 --- a/src/core/tools/RunSlashCommandTool.ts +++ b/src/core/tools/RunSlashCommandTool.ts @@ -4,6 +4,7 @@ import { getCommand, getCommandNames } from "../../services/command/commands" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" +import { getModeBySlug } from "../../shared/modes" interface RunSlashCommandParams { command: string @@ -74,6 +75,7 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { args: args, source: command.source, description: command.description, + mode: command.mode, }) const didApprove = await askApproval("tool", toolMessage) @@ -82,6 +84,15 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { return } + // Switch mode if specified in the command frontmatter + if (command.mode) { + const provider = task.providerRef.deref() + const targetMode = getModeBySlug(command.mode, (await provider?.getState())?.customModes) + if (targetMode) { + await provider?.handleModeSwitch(command.mode) + } + } + // Build the result message let result = `Command: /${commandName}` @@ -93,6 +104,10 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { result += `\nArgument hint: ${command.argumentHint}` } + if (command.mode) { + result += `\nMode: ${command.mode}` + } + if (args) { result += `\nProvided arguments: ${args}` } diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 7caaeb6d55..d9c20115ea 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -187,6 +187,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { pushToolResult(message) await task.diffViewProvider.reset() + this.resetPartialState() task.processQueuedMessages() @@ -194,15 +195,26 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } catch (error) { await handleError("writing file", error as Error) await task.diffViewProvider.reset() + this.resetPartialState() return } } + // Track the last seen path during streaming to detect when the path has stabilized + private lastSeenPartialPath: string | undefined = undefined + override async handlePartial(task: Task, block: ToolUse<"write_to_file">): Promise { const relPath: string | undefined = block.params.path let newContent: string | undefined = block.params.content - if (!relPath || newContent === undefined) { + // During streaming, the partial-json library may return truncated string values + // when chunk boundaries fall mid-value. To avoid creating files at incorrect paths, + // we wait until the path stops changing between consecutive partial blocks before + // creating the file. This ensures we have the complete, final path value. + const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === relPath + this.lastSeenPartialPath = relPath + + if (!pathHasStabilized || !relPath || newContent === undefined) { return } @@ -259,6 +271,13 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ) } } + + /** + * Reset state when the tool finishes (called from execute or on error) + */ + resetPartialState(): void { + this.lastSeenPartialPath = undefined + } } export const writeToFileTool = new WriteToFileTool() diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index c98211f940..f178e38026 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -305,6 +305,13 @@ describe("read_file tool with maxReadFileLine setting", () => { mockedPathResolve.mockReturnValue(absoluteFilePath) mockedIsBinaryFile.mockResolvedValue(false) + // Mock fsPromises.stat to return a file (not directory) by default + fsPromises.stat.mockResolvedValue({ + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + } as any) + mockInputContent = fileContent // Setup the extractTextFromFile mock implementation with the current mockInputContent @@ -612,7 +619,12 @@ describe("read_file tool output structure", () => { // CRITICAL: Reset fsPromises mocks to prevent cross-test contamination fsPromises.stat.mockClear() - fsPromises.stat.mockResolvedValue({ size: 1024 }) + fsPromises.stat.mockResolvedValue({ + size: 1024, + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + } as any) fsPromises.readFile.mockClear() // Use shared mock setup function @@ -852,7 +864,7 @@ describe("read_file tool output structure", () => { fsPromises.stat = vi.fn().mockImplementation((filePath) => { const normalizedFilePath = path.normalize(filePath.toString()) const image = smallImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) - return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) + return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false }) }) // Mock path.resolve for each image @@ -928,7 +940,7 @@ describe("read_file tool output structure", () => { fsPromises.stat = vi.fn().mockImplementation((filePath) => { const normalizedFilePath = path.normalize(filePath.toString()) const image = largeImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) - return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) + return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false }) }) // Mock path.resolve for each image @@ -1012,9 +1024,9 @@ describe("read_file tool output structure", () => { const normalizedFilePath = path.normalize(filePath.toString()) const image = exactLimitImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) if (image) { - return Promise.resolve({ size: image.sizeKB * 1024 }) + return Promise.resolve({ size: image.sizeKB * 1024, isDirectory: () => false }) } - return Promise.resolve({ size: 1024 * 1024 }) // Default 1MB + return Promise.resolve({ size: 1024 * 1024, isDirectory: () => false }) // Default 1MB }) // Mock path.resolve @@ -1085,7 +1097,7 @@ describe("read_file tool output structure", () => { const fileName = path.basename(filePath) const baseName = path.parse(fileName).name const image = mixedImages.find((img) => img.path.includes(baseName)) - return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) + return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false }) }) // Mock provider state with 5MB individual limit @@ -1139,9 +1151,9 @@ describe("read_file tool output structure", () => { const normalizedFilePath = path.normalize(filePath.toString()) const file = testImages.find((f) => normalizedFilePath.includes(path.normalize(f.path))) if (file) { - return { size: file.sizeMB * 1024 * 1024 } + return { size: file.sizeMB * 1024 * 1024, isDirectory: () => false } } - return { size: 1024 * 1024 } // Default 1MB + return { size: 1024 * 1024, isDirectory: () => false } // Default 1MB }) const imagePaths = testImages.map((img) => img.path) @@ -1201,7 +1213,7 @@ describe("read_file tool output structure", () => { // Setup - first call with images that use memory const firstBatch = [{ path: "test/first.png", sizeKB: 10240 }] // 10MB - fsPromises.stat = vi.fn().mockResolvedValue({ size: 10240 * 1024 }) + fsPromises.stat = vi.fn().mockResolvedValue({ size: 10240 * 1024, isDirectory: () => false }) mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) // Execute first batch @@ -1254,7 +1266,7 @@ describe("read_file tool output structure", () => { mockedCountFileLines.mockClear() // Reset mocks for second batch - fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024 }) + fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024, isDirectory: () => false }) fsPromises.readFile.mockResolvedValue( Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", @@ -1303,7 +1315,7 @@ describe("read_file tool output structure", () => { fsPromises.stat = vi.fn().mockImplementation((filePath) => { const normalizedFilePath = path.normalize(filePath.toString()) const image = manyImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) - return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) + return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false }) }) // Mock path.resolve @@ -1350,7 +1362,7 @@ describe("read_file tool output structure", () => { // First invocation - use 15MB of memory const firstBatch = [{ path: "test/large1.png", sizeKB: 15360 }] // 15MB - fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024 }) + fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024, isDirectory: () => false }) mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) // Execute first batch @@ -1371,7 +1383,7 @@ describe("read_file tool output structure", () => { fsPromises.readFile.mockClear() mockedPathResolve.mockClear() - fsPromises.stat = vi.fn().mockResolvedValue({ size: 18432 * 1024 }) + fsPromises.stat = vi.fn().mockResolvedValue({ size: 18432 * 1024, isDirectory: () => false }) fsPromises.readFile.mockResolvedValue(imageBuffer) mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) @@ -1429,6 +1441,37 @@ describe("read_file tool output structure", () => { `File: ${testFilePath}\nError: Access to ${testFilePath} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.`, ) }) + + it("should provide helpful error when trying to read a directory", async () => { + // Setup - mock fsPromises.stat to indicate the path is a directory + const dirPath = "test/my-directory" + const absoluteDirPath = "/test/my-directory" + + mockedPathResolve.mockReturnValue(absoluteDirPath) + + // Mock fs/promises stat to return directory + fsPromises.stat.mockResolvedValue({ + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + } as any) + + // Mock isBinaryFile won't be called since we check directory first + mockedIsBinaryFile.mockResolvedValue(false) + + // Execute + const result = await executeReadFileTool({ args: `${dirPath}` }) + + // Verify - native format for error + expect(result).toContain(`File: ${dirPath}`) + expect(result).toContain(`Error: Error reading file: Cannot read '${dirPath}' because it is a directory`) + expect(result).toContain("use the list_files tool instead") + + // Verify that task.say was called with the error + expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Cannot read")) + expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("is a directory")) + expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("list_files tool")) + }) }) }) @@ -1460,7 +1503,12 @@ describe("read_file tool with image support", () => { // CRITICAL: Reset fsPromises.stat to prevent cross-test contamination fsPromises.stat.mockClear() - fsPromises.stat.mockResolvedValue({ size: 1024 }) + fsPromises.stat.mockResolvedValue({ + size: 1024, + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + } as any) // Use shared mock setup function with local variables const mocks = createMockCline() @@ -1771,3 +1819,195 @@ describe("read_file tool with image support", () => { }) }) }) + +describe("read_file tool concurrent file reads limit", () => { + const mockedCountFileLines = vi.mocked(countFileLines) + const mockedIsBinaryFile = vi.mocked(isBinaryFile) + const mockedPathResolve = vi.mocked(path.resolve) + + let mockCline: any + let mockProvider: any + let toolResult: ToolResponse | undefined + + beforeEach(() => { + // Clear specific mocks + mockedCountFileLines.mockClear() + mockedIsBinaryFile.mockClear() + mockedPathResolve.mockClear() + addLineNumbersMock.mockClear() + toolResultMock.mockClear() + + // Use shared mock setup function + const mocks = createMockCline() + mockCline = mocks.mockCline + mockProvider = mocks.mockProvider + + // Disable image support for these tests + setImageSupport(mockCline, false) + + mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + mockedIsBinaryFile.mockResolvedValue(false) + mockedCountFileLines.mockResolvedValue(10) + + // Mock fsPromises.stat to return a file (not directory) by default + fsPromises.stat.mockResolvedValue({ + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + } as any) + + toolResult = undefined + }) + + async function executeReadFileToolWithLimit( + fileCount: number, + maxConcurrentFileReads: number, + ): Promise { + // Setup provider state with the specified limit + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxConcurrentFileReads, + maxImageFileSize: 20, + maxTotalImageSize: 20, + }) + + // Create args with the specified number of files + const files = Array.from({ length: fileCount }, (_, i) => `file${i + 1}.txt`) + const argsContent = files.join("") + + const toolUse: ReadFileToolUse = { + type: "tool_use", + name: "read_file", + params: { args: argsContent }, + partial: false, + } + + // Configure mocks for successful file reads + mockReadFileWithTokenBudget.mockResolvedValue({ + content: "test content", + tokenCount: 10, + lineCount: 1, + complete: true, + }) + + await readFileTool.handle(mockCline, toolUse, { + askApproval: mockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { + toolResult = result + }, + removeClosingTag: (_: ToolParamName, content?: string) => content ?? "", + toolProtocol: "xml", + }) + + return toolResult + } + + it("should reject when file count exceeds maxConcurrentFileReads", async () => { + // Try to read 6 files when limit is 5 + const result = await executeReadFileToolWithLimit(6, 5) + + // Verify error result + expect(result).toContain("Error: Too many files requested") + expect(result).toContain("You attempted to read 6 files") + expect(result).toContain("but the concurrent file reads limit is 5") + expect(result).toContain("Please read files in batches of 5 or fewer") + + // Verify error tracking + expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Too many files requested")) + }) + + it("should allow reading files when count equals maxConcurrentFileReads", async () => { + // Try to read exactly 5 files when limit is 5 + const result = await executeReadFileToolWithLimit(5, 5) + + // Should not contain error + expect(result).not.toContain("Error: Too many files requested") + + // Should contain file results + expect(typeof result === "string" ? result : JSON.stringify(result)).toContain("file1.txt") + }) + + it("should allow reading files when count is below maxConcurrentFileReads", async () => { + // Try to read 3 files when limit is 5 + const result = await executeReadFileToolWithLimit(3, 5) + + // Should not contain error + expect(result).not.toContain("Error: Too many files requested") + + // Should contain file results + expect(typeof result === "string" ? result : JSON.stringify(result)).toContain("file1.txt") + }) + + it("should respect custom maxConcurrentFileReads value of 1", async () => { + // Try to read 2 files when limit is 1 + const result = await executeReadFileToolWithLimit(2, 1) + + // Verify error result with limit of 1 + expect(result).toContain("Error: Too many files requested") + expect(result).toContain("You attempted to read 2 files") + expect(result).toContain("but the concurrent file reads limit is 1") + }) + + it("should allow single file read when maxConcurrentFileReads is 1", async () => { + // Try to read 1 file when limit is 1 + const result = await executeReadFileToolWithLimit(1, 1) + + // Should not contain error + expect(result).not.toContain("Error: Too many files requested") + + // Should contain file result + expect(typeof result === "string" ? result : JSON.stringify(result)).toContain("file1.txt") + }) + + it("should respect higher maxConcurrentFileReads value", async () => { + // Try to read 15 files when limit is 10 + const result = await executeReadFileToolWithLimit(15, 10) + + // Verify error result + expect(result).toContain("Error: Too many files requested") + expect(result).toContain("You attempted to read 15 files") + expect(result).toContain("but the concurrent file reads limit is 10") + }) + + it("should use default value of 5 when maxConcurrentFileReads is not set", async () => { + // Setup provider state without maxConcurrentFileReads + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 20, + maxTotalImageSize: 20, + }) + + // Create args with 6 files + const files = Array.from({ length: 6 }, (_, i) => `file${i + 1}.txt`) + const argsContent = files.join("") + + const toolUse: ReadFileToolUse = { + type: "tool_use", + name: "read_file", + params: { args: argsContent }, + partial: false, + } + + mockReadFileWithTokenBudget.mockResolvedValue({ + content: "test content", + tokenCount: 10, + lineCount: 1, + complete: true, + }) + + await readFileTool.handle(mockCline, toolUse, { + askApproval: mockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { + toolResult = result + }, + removeClosingTag: (_: ToolParamName, content?: string) => content ?? "", + toolProtocol: "xml", + }) + + // Should use default limit of 5 and reject 6 files + expect(toolResult).toContain("Error: Too many files requested") + expect(toolResult).toContain("but the concurrent file reads limit is 5") + }) +}) diff --git a/src/core/tools/__tests__/runSlashCommandTool.spec.ts b/src/core/tools/__tests__/runSlashCommandTool.spec.ts index e3c8180e38..eef6259deb 100644 --- a/src/core/tools/__tests__/runSlashCommandTool.spec.ts +++ b/src/core/tools/__tests__/runSlashCommandTool.spec.ts @@ -307,4 +307,133 @@ Deploy application to production`, expect(mockTask.consecutiveMistakeCount).toBe(0) }) + + it("should switch mode when mode is specified in command", async () => { + const mockHandleModeSwitch = vi.fn() + const block: ToolUse<"run_slash_command"> = { + type: "tool_use" as const, + name: "run_slash_command" as const, + params: { + command: "debug-app", + }, + partial: false, + } + + const mockCommand = { + name: "debug-app", + content: "Start debugging the application", + source: "project" as const, + filePath: ".roo/commands/debug-app.md", + description: "Debug the application", + mode: "debug", + } + + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + experiments: { + runSlashCommand: true, + }, + customModes: undefined, + }), + handleModeSwitch: mockHandleModeSwitch, + }) + + vi.mocked(getCommand).mockResolvedValue(mockCommand) + + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockHandleModeSwitch).toHaveBeenCalledWith("debug") + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + `Command: /debug-app +Description: Debug the application +Mode: debug +Source: project + +--- Command Content --- + +Start debugging the application`, + ) + }) + + it("should not switch mode when mode is not specified in command", async () => { + const mockHandleModeSwitch = vi.fn() + const block: ToolUse<"run_slash_command"> = { + type: "tool_use" as const, + name: "run_slash_command" as const, + params: { + command: "test", + }, + partial: false, + } + + const mockCommand = { + name: "test", + content: "Run tests", + source: "project" as const, + filePath: ".roo/commands/test.md", + description: "Run project tests", + } + + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + experiments: { + runSlashCommand: true, + }, + customModes: undefined, + }), + handleModeSwitch: mockHandleModeSwitch, + }) + + vi.mocked(getCommand).mockResolvedValue(mockCommand) + + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockHandleModeSwitch).not.toHaveBeenCalled() + }) + + it("should include mode in askApproval message when mode is specified", async () => { + const block: ToolUse<"run_slash_command"> = { + type: "tool_use" as const, + name: "run_slash_command" as const, + params: { + command: "debug-app", + }, + partial: false, + } + + const mockCommand = { + name: "debug-app", + content: "Start debugging", + source: "project" as const, + filePath: ".roo/commands/debug-app.md", + description: "Debug the application", + mode: "debug", + } + + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + experiments: { + runSlashCommand: true, + }, + customModes: undefined, + }), + handleModeSwitch: vi.fn(), + }) + + vi.mocked(getCommand).mockResolvedValue(mockCommand) + + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockCallbacks.askApproval).toHaveBeenCalledWith( + "tool", + JSON.stringify({ + tool: "runSlashCommand", + command: "debug-app", + args: undefined, + source: "project", + description: "Debug the application", + mode: "debug", + }), + ) + }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 3970a99f06..fd791729b4 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -111,6 +111,7 @@ describe("writeToFileTool", () => { beforeEach(() => { vi.clearAllMocks() + writeToFileTool.resetPartialState() mockedPathResolve.mockReturnValue(absoluteFilePath) mockedFileExistsAtPath.mockResolvedValue(false) @@ -278,10 +279,14 @@ describe("writeToFileTool", () => { ) it.skipIf(process.platform === "win32")( - "creates parent directories early when file does not exist (partial)", + "creates parent directories when path has stabilized (partial)", async () => { + // First call - path not yet stabilized await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockedCreateDirectoriesForFile).not.toHaveBeenCalled() + // Second call with same path - path is now stabilized + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) expect(mockedCreateDirectoriesForFile).toHaveBeenCalledWith(absoluteFilePath) }, ) @@ -394,9 +399,14 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() }) - it("streams content updates during partial execution", async () => { + it("streams content updates during partial execution after path stabilizes", async () => { + // First call - path not yet stabilized, early return (no file operations) await executeWriteFileTool({}, { isPartial: true }) + expect(mockCline.ask).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + // Second call with same path - path is now stabilized, file operations proceed + await executeWriteFileTool({}, { isPartial: true }) expect(mockCline.ask).toHaveBeenCalled() expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath) expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false) @@ -442,11 +452,15 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() }) - it("handles partial streaming errors", async () => { + it("handles partial streaming errors after path stabilizes", async () => { mockCline.diffViewProvider.open.mockRejectedValue(new Error("Open failed")) + // First call - path not yet stabilized, no error yet await executeWriteFileTool({}, { isPartial: true }) + expect(mockHandleError).not.toHaveBeenCalled() + // Second call with same path - path is now stabilized, error occurs + await executeWriteFileTool({}, { isPartial: true }) expect(mockHandleError).toHaveBeenCalledWith("handling partial write_to_file", expect.any(Error)) }) }) diff --git a/src/core/tools/simpleReadFileTool.ts b/src/core/tools/simpleReadFileTool.ts deleted file mode 100644 index 1b41e9e9d6..0000000000 --- a/src/core/tools/simpleReadFileTool.ts +++ /dev/null @@ -1,289 +0,0 @@ -import path from "path" -import { isBinaryFile } from "isbinaryfile" - -import { Task } from "../task/Task" -import { ClineSayTool } from "../../shared/ExtensionMessage" -import { formatResponse } from "../prompts/responses" -import { t } from "../../i18n" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { RecordSource } from "../context-tracking/FileContextTrackerTypes" -import { isPathOutsideWorkspace } from "../../utils/pathUtils" -import { getReadablePath } from "../../utils/path" -import { countFileLines } from "../../integrations/misc/line-counter" -import { readLines } from "../../integrations/misc/read-lines" -import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" -import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" -import { ToolProtocol, isNativeProtocol } from "@roo-code/types" -import { - DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - isSupportedImageFormat, - validateImageForProcessing, - processImageFile, -} from "./helpers/imageHelpers" - -/** - * Simplified read file tool for models that only support single file reads - * Uses the format: file/path.ext - * - * This is a streamlined version of readFileTool that: - * - Only accepts a single path parameter - * - Does not support multiple files - * - Does not support line ranges - * - Has simpler XML parsing - */ -export async function simpleReadFileTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - _removeClosingTag: RemoveClosingTag, - toolProtocol?: ToolProtocol, -) { - const filePath: string | undefined = block.params.path - - // Check if the current model supports images - const modelInfo = cline.api.getModel().info - const supportsImages = modelInfo.supportsImages ?? false - - // Handle partial message - if (block.partial) { - const fullPath = filePath ? path.resolve(cline.cwd, filePath) : "" - const sharedMessageProps: ClineSayTool = { - tool: "readFile", - path: getReadablePath(cline.cwd, filePath || ""), - isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, - } - const partialMessage = JSON.stringify({ - ...sharedMessageProps, - content: undefined, - } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } - - // Validate path parameter - if (!filePath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("read_file") - const errorMsg = await cline.sayAndCreateMissingParamError("read_file", "path") - pushToolResult(`${errorMsg}`) - return - } - - const relPath = filePath - const fullPath = path.resolve(cline.cwd, relPath) - - try { - // Check RooIgnore validation - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) - if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - const errorMsg = formatResponse.rooIgnoreError(relPath) - pushToolResult(`${relPath}${errorMsg}`) - return - } - - // Get max read file line setting - const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} - - // Create approval message - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - let lineSnippet = "" - if (maxReadFileLine === 0) { - lineSnippet = t("tools:readFile.definitionsOnly") - } else if (maxReadFileLine > 0) { - lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine }) - } - - const completeMessage = JSON.stringify({ - tool: "readFile", - path: getReadablePath(cline.cwd, relPath), - isOutsideWorkspace, - content: fullPath, - reason: lineSnippet, - } satisfies ClineSayTool) - - const { response, text, images } = await cline.ask("tool", completeMessage, false) - - if (response !== "yesButtonClicked") { - // Handle denial - if (text) { - await cline.say("user_feedback", text, images) - } - cline.didRejectTool = true - - const statusMessage = text ? formatResponse.toolDeniedWithFeedback(text) : formatResponse.toolDenied() - - pushToolResult(`${statusMessage}\n${relPath}Denied by user`) - return - } - - // Handle approval with feedback - if (text) { - await cline.say("user_feedback", text, images) - } - - // Process the file - const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) - - // Handle binary files - if (isBinary) { - const fileExtension = path.extname(relPath).toLowerCase() - const supportedBinaryFormats = getSupportedBinaryFormats() - - // Check if it's a supported image format - if (isSupportedImageFormat(fileExtension)) { - try { - const { - maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - } = (await cline.providerRef.deref()?.getState()) ?? {} - - // Validate image for processing - const validationResult = await validateImageForProcessing( - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - 0, // No cumulative memory for single file - ) - - if (!validationResult.isValid) { - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - pushToolResult( - `${relPath}\n${validationResult.notice}\n`, - ) - return - } - - // Process the image - const imageResult = await processImageFile(fullPath) - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - // Return result with image data - const result = formatResponse.toolResult( - `${relPath}\n${imageResult.notice}\n`, - supportsImages ? [imageResult.dataUrl] : undefined, - ) - - if (typeof result === "string") { - pushToolResult(result) - } else { - pushToolResult(result) - } - return - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - pushToolResult( - `${relPath}Error reading image file: ${errorMsg}`, - ) - await handleError( - `reading image file ${relPath}`, - error instanceof Error ? error : new Error(errorMsg), - ) - return - } - } - - // Check if it's a supported binary format that can be processed - if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) { - // For supported binary formats (.pdf, .docx, .ipynb), continue to extractTextFromFile - // Fall through to the normal extractTextFromFile processing below - } else { - // Handle unknown binary format - const fileFormat = fileExtension.slice(1) || "bin" - pushToolResult( - `${relPath}\nBinary file - content not displayed\n`, - ) - return - } - } - - // Handle definitions-only mode - if (maxReadFileLine === 0) { - try { - const defResult = await parseSourceCodeDefinitionsForFile(fullPath, cline.rooIgnoreController) - if (defResult) { - let xmlInfo = `Showing only definitions. Use standard read_file if you need to read actual content\n` - pushToolResult( - `${relPath}\n${defResult}\n${xmlInfo}`, - ) - } - } catch (error) { - if (error instanceof Error && error.message.startsWith("Unsupported language:")) { - console.warn(`[simple_read_file] Warning: ${error.message}`) - } else { - console.error( - `[simple_read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - return - } - - // Handle files exceeding line threshold - if (maxReadFileLine > 0 && totalLines > maxReadFileLine) { - const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0)) - const lineRangeAttr = ` lines="1-${maxReadFileLine}"` - let xmlInfo = `\n${content}\n` - - try { - const defResult = await parseSourceCodeDefinitionsForFile(fullPath, cline.rooIgnoreController) - if (defResult) { - xmlInfo += `${defResult}\n` - } - xmlInfo += `Showing only ${maxReadFileLine} of ${totalLines} total lines. File is too large for complete display\n` - pushToolResult(`${relPath}\n${xmlInfo}`) - } catch (error) { - if (error instanceof Error && error.message.startsWith("Unsupported language:")) { - console.warn(`[simple_read_file] Warning: ${error.message}`) - } else { - console.error( - `[simple_read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - return - } - - // Handle normal file read - const content = await extractTextFromFile(fullPath) - const lineRangeAttr = ` lines="1-${totalLines}"` - let xmlInfo = totalLines > 0 ? `\n${content}\n` : `` - - if (totalLines === 0) { - xmlInfo += `File is empty\n` - } - - // Track file read - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - // Return the result - if (text) { - const statusMessage = formatResponse.toolApprovedWithFeedback(text) - pushToolResult(`${statusMessage}\n${relPath}\n${xmlInfo}`) - } else { - pushToolResult(`${relPath}\n${xmlInfo}`) - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - pushToolResult(`${relPath}Error reading file: ${errorMsg}`) - await handleError(`reading file ${relPath}`, error instanceof Error ? error : new Error(errorMsg)) - } -} - -/** - * Get description for the simple read file tool - * @param blockName The name of the tool block - * @param blockParams The parameters passed to the tool - * @returns A description string for the tool use - */ -export function getSimpleReadFileToolDescription(blockName: string, blockParams: any): string { - if (blockParams.path) { - return `[${blockName} for '${blockParams.path}']` - } else { - return `[${blockName} with missing path]` - } -} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9434286d0d..a2a400660e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -71,6 +71,7 @@ import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckp import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" +import { SkillsManager } from "../../services/skills/SkillsManager" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -137,6 +138,7 @@ export class ClineProvider private codeIndexManager?: CodeIndexManager private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class protected mcpHub?: McpHub // Change from private to protected + protected skillsManager?: SkillsManager private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void @@ -153,7 +155,7 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number - public readonly latestAnnouncementId = "dec-2025-v3.37.0-minimax-m21-glm47-custom-tools" // v3.37.0 MiniMax M2.1, GLM-4.7, & Experimental Custom Tools + public readonly latestAnnouncementId = "jan-2026-v3.39.0-sticky-profiles-image-mentions-brrr" // v3.39.0 Sticky Profiles, Image @Mentions, BRRR Mode public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager @@ -197,6 +199,12 @@ export class ClineProvider this.log(`Failed to initialize MCP Hub: ${error}`) }) + // Initialize Skills Manager for skill discovery + this.skillsManager = new SkillsManager(this) + this.skillsManager.initialize().catch((error) => { + this.log(`Failed to initialize Skills Manager: ${error}`) + }) + this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) // Forward task events to the provider. @@ -603,6 +611,8 @@ export class ClineProvider this._workspaceTracker = undefined await this.mcpHub?.unregisterClient() this.mcpHub = undefined + await this.skillsManager?.dispose() + this.skillsManager = undefined this.marketplaceManager?.cleanup() this.customModesManager?.dispose() this.log("Disposed all disposables") @@ -880,32 +890,67 @@ export class ClineProvider await this.updateGlobalState("mode", historyItem.mode) // Load the saved API config for the restored mode if it exists. - const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode) - const listApiConfig = await this.providerSettingsManager.listConfig() + // Skip mode-based profile activation if historyItem.apiConfigName exists, + // since the task's specific provider profile will override it anyway. + if (!historyItem.apiConfigName) { + const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode) + const listApiConfig = await this.providerSettingsManager.listConfig() - // Update listApiConfigMeta first to ensure UI has latest data. - await this.updateGlobalState("listApiConfigMeta", listApiConfig) + // Update listApiConfigMeta first to ensure UI has latest data. + await this.updateGlobalState("listApiConfigMeta", listApiConfig) - // If this mode has a saved config, use it. - if (savedConfigId) { - const profile = listApiConfig.find(({ id }) => id === savedConfigId) + // If this mode has a saved config, use it. + if (savedConfigId) { + const profile = listApiConfig.find(({ id }) => id === savedConfigId) - if (profile?.name) { - try { - await this.activateProviderProfile({ name: profile.name }) - } catch (error) { - // Log the error but continue with task restoration. - this.log( - `Failed to restore API configuration for mode '${historyItem.mode}': ${ - error instanceof Error ? error.message : String(error) - }. Continuing with default configuration.`, - ) - // The task will continue with the current/default configuration. + if (profile?.name) { + try { + await this.activateProviderProfile({ name: profile.name }) + } catch (error) { + // Log the error but continue with task restoration. + this.log( + `Failed to restore API configuration for mode '${historyItem.mode}': ${ + error instanceof Error ? error.message : String(error) + }. Continuing with default configuration.`, + ) + // The task will continue with the current/default configuration. + } } } } } + // If the history item has a saved API config name (provider profile), restore it. + // This overrides any mode-based config restoration above, because the task's + // specific provider profile takes precedence over mode defaults. + if (historyItem.apiConfigName) { + const listApiConfig = await this.providerSettingsManager.listConfig() + // Keep global state/UI in sync with latest profiles for parity with mode restoration above. + await this.updateGlobalState("listApiConfigMeta", listApiConfig) + const profile = listApiConfig.find(({ name }) => name === historyItem.apiConfigName) + + if (profile?.name) { + try { + await this.activateProviderProfile( + { name: profile.name }, + { persistModeConfig: false, persistTaskHistory: false }, + ) + } catch (error) { + // Log the error but continue with task restoration. + this.log( + `Failed to restore API configuration '${historyItem.apiConfigName}' for task: ${ + error instanceof Error ? error.message : String(error) + }. Continuing with current configuration.`, + ) + } + } else { + // Profile no longer exists, log warning but continue + this.log( + `Provider profile '${historyItem.apiConfigName}' from history no longer exists. Using current configuration.`, + ) + } + } + const { apiConfiguration, diffEnabled: enableDiff, @@ -1389,6 +1434,9 @@ export class ClineProvider // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + + // Keep the current task's sticky provider profile in sync with the newly-activated profile. + await this.persistStickyProviderProfileToCurrentTask(name) } else { await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) } @@ -1428,9 +1476,42 @@ export class ClineProvider await this.postStateToWebview() } - async activateProviderProfile(args: { name: string } | { id: string }) { + private async persistStickyProviderProfileToCurrentTask(apiConfigName: string): Promise { + const task = this.getCurrentTask() + if (!task) { + return + } + + try { + // Update in-memory state immediately so sticky behavior works even before the task has + // been persisted into taskHistory (it will be captured on the next save). + task.setTaskApiConfigName(apiConfigName) + + const history = this.getGlobalState("taskHistory") ?? [] + const taskHistoryItem = history.find((item) => item.id === task.taskId) + + if (taskHistoryItem) { + await this.updateTaskHistory({ ...taskHistoryItem, apiConfigName }) + } + } catch (error) { + // If persistence fails, log the error but don't fail the profile switch. + this.log( + `Failed to persist provider profile switch for task ${task.taskId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + + async activateProviderProfile( + args: { name: string } | { id: string }, + options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean }, + ) { const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) + const persistModeConfig = options?.persistModeConfig ?? true + const persistTaskHistory = options?.persistTaskHistory ?? true + // See `upsertProviderProfile` for a description of what this is doing. await Promise.all([ this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), @@ -1440,12 +1521,19 @@ export class ClineProvider const { mode } = await this.getState() - if (id) { + if (id && persistModeConfig) { await this.providerSettingsManager.setModeConfig(mode, id) } + // Change the provider for the current task. this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + // Update the current task's sticky provider profile, unless this activation is + // being used purely as a non-persisting restoration (e.g., reopening a task from history). + if (persistTaskHistory) { + await this.persistStickyProviderProfileToCurrentTask(name) + } + await this.postStateToWebview() if (providerSettings.apiProvider) { @@ -1863,6 +1951,7 @@ export class ClineProvider browserToolEnabled, telemetrySetting, showRooIgnoredFiles, + enableSubfolderRules, language, maxReadFileLine, maxImageFileSize, @@ -1896,7 +1985,6 @@ export class ClineProvider imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, - openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, isBrowserSessionActive, } = await this.getState() @@ -2011,6 +2099,7 @@ export class ClineProvider telemetryKey, machineId, showRooIgnoredFiles: showRooIgnoredFiles ?? false, + enableSubfolderRules: enableSubfolderRules ?? false, language: language ?? formatLanguage(vscode.env.language), renderContext: this.renderContext, maxReadFileLine: maxReadFileLine ?? -1, @@ -2025,6 +2114,7 @@ export class ClineProvider enterBehavior: enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, + cloudAuthSkipModel: this.context.globalState.get("roo-auth-skip-model") ?? false, cloudOrganizations, sharingEnabled: sharingEnabled ?? false, publicSharingEnabled: publicSharingEnabled ?? false, @@ -2066,7 +2156,6 @@ export class ClineProvider imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, - openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, claudeCodeIsAuthenticated: await (async () => { try { @@ -2258,10 +2347,10 @@ export class ClineProvider customModes, maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform, browserToolEnabled: stateValues.browserToolEnabled ?? true, telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, + enableSubfolderRules: stateValues.enableSubfolderRules ?? false, maxReadFileLine: stateValues.maxReadFileLine ?? -1, maxImageFileSize: stateValues.maxImageFileSize ?? 5, maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, @@ -2442,6 +2531,10 @@ export class ClineProvider return this.mcpHub } + public getSkillsManager(): SkillsManager | undefined { + return this.skillsManager + } + /** * Check if the current state is compliant with MDM policy * @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9eb01406ff..daab32e93f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -568,6 +568,7 @@ describe("ClineProvider", () => { browserToolEnabled: true, telemetrySetting: "unset", showRooIgnoredFiles: false, + enableSubfolderRules: false, renderContext: "sidebar", maxReadFileLine: 500, maxImageFileSize: 5, @@ -3050,7 +3051,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0]]) expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }]) // Verify submitUserMessage was called with the edited content - expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with preserved images", undefined) + expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with preserved images", []) }) test("handles editing messages with file attachments", async () => { @@ -3103,7 +3104,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) expect(mockCline.overwriteClineMessages).toHaveBeenCalled() - expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with file attachment", undefined) + expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with file attachment", []) }) }) @@ -3634,7 +3635,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: largeEditedContent }) expect(mockCline.overwriteClineMessages).toHaveBeenCalled() - expect(mockCline.submitUserMessage).toHaveBeenCalledWith(largeEditedContent, undefined) + expect(mockCline.submitUserMessage).toHaveBeenCalledWith(largeEditedContent, []) }) test("handles deleting messages with large payloads", async () => { diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts new file mode 100644 index 0000000000..3df4408b71 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -0,0 +1,883 @@ +// npx vitest run core/webview/__tests__/ClineProvider.sticky-profile.spec.ts + +import * as vscode from "vscode" +import { TelemetryService } from "@roo-code/telemetry" +import { ClineProvider } from "../ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" +import type { HistoryItem } from "@roo-code/types" + +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + Uri: { + joinPath: vi.fn(), + file: vi.fn(), + }, + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + onDidChangeConfiguration: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + })), + onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + }, + env: { + uriScheme: "vscode", + language: "en", + appName: "Visual Studio Code", + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + version: "1.85.0", +})) + +// Create a counter for unique task IDs. +let taskIdCounter = 0 + +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation((options) => ({ + taskId: options.taskId || `test-task-id-${++taskIdCounter}`, + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + abortTask: vi.fn(), + handleWebviewAskResponse: vi.fn(), + getTaskNumber: vi.fn().mockReturnValue(0), + setTaskNumber: vi.fn(), + setParentTask: vi.fn(), + setRootTask: vi.fn(), + emit: vi.fn(), + parentTask: options.parentTask, + updateApiConfiguration: vi.fn(), + setTaskApiConfigName: vi.fn(), + _taskApiConfigName: options.historyItem?.apiConfigName, + taskApiConfigName: options.historyItem?.apiConfigName, + })), +})) + +vi.mock("../../prompts/sections/custom-instructions") + +vi.mock("../../../utils/safeWriteJson") + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn().mockReturnValue({ + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + }), + }), +})) + +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(() => ({ + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + })), +})) + +vi.mock("../../diff/strategies/multi-search-replace", () => ({ + MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(() => ({ + getToolDescription: () => "test", + getName: () => "test-strategy", + applyDiff: vi.fn(), + })), +})) + +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return { + isAuthenticated: vi.fn().mockReturnValue(false), + } + }, + }, + BridgeOrchestrator: { + isEnabled: vi.fn().mockReturnValue(false), + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +vi.mock("../../../shared/modes", () => ({ + modes: [ + { + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit", "browser"], + }, + { + slug: "architect", + name: "Architect Mode", + roleDefinition: "You are an architect", + groups: ["read", "edit"], + }, + ], + getModeBySlug: vi.fn().mockReturnValue({ + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit", "browser"], + }), + defaultModeSlug: "code", +})) + +vi.mock("../../prompts/system", () => ({ + SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"), + codeMode: "code", +})) + +vi.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + flushModels: vi.fn(), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockImplementation(async () => Promise.resolve()), +})) + +vi.mock("fs/promises", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + hasInstance: vi.fn().mockReturnValue(true), + createInstance: vi.fn(), + get instance() { + return { + trackEvent: vi.fn(), + trackError: vi.fn(), + setProvider: vi.fn(), + captureModeSwitch: vi.fn(), + } + }, + }, +})) + +describe("ClineProvider - Sticky Provider Profile", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockWebviewView: vscode.WebviewView + let mockPostMessage: any + + beforeEach(() => { + vi.clearAllMocks() + taskIdCounter = 0 + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const globalState: Record = { + mode: "code", + currentApiConfigName: "default-profile", + } + + const secrets: Record = {} + + mockContext = { + extensionPath: "/test/path", + extensionUri: {} as vscode.Uri, + globalState: { + get: vi.fn().mockImplementation((key: string) => globalState[key]), + update: vi.fn().mockImplementation((key: string, value: string | undefined) => { + globalState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => Object.keys(globalState)), + }, + secrets: { + get: vi.fn().mockImplementation((key: string) => secrets[key]), + store: vi.fn().mockImplementation((key: string, value: string | undefined) => { + secrets[key] = value + return Promise.resolve() + }), + delete: vi.fn().mockImplementation((key: string) => { + delete secrets[key] + return Promise.resolve() + }), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + + mockPostMessage = vi.fn() + + mockWebviewView = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn().mockImplementation((callback) => { + callback() + return { dispose: vi.fn() } + }), + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView + + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Mock getMcpHub method + provider.getMcpHub = vi.fn().mockReturnValue({ + listTools: vi.fn().mockResolvedValue([]), + callTool: vi.fn().mockResolvedValue({ content: [] }), + listResources: vi.fn().mockResolvedValue([]), + readResource: vi.fn().mockResolvedValue({ contents: [] }), + getAllServers: vi.fn().mockReturnValue([]), + }) + }) + + describe("activateProviderProfile", () => { + beforeEach(async () => { + await provider.resolveWebviewView(mockWebviewView) + }) + + it("should save provider profile to task metadata when switching profiles", async () => { + // Create a mock task + const mockTask = { + taskId: "test-task-id", + _taskApiConfigName: "default-profile", + setTaskApiConfigName: vi.fn(), + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } + + // Add task to provider stack + await provider.addClineToStack(mockTask as any) + + // Mock getGlobalState to return task history + vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + { + id: mockTask.taskId, + ts: Date.now(), + task: "Test task", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + }, + ]) + + // Mock updateTaskHistory to track calls + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementation(() => Promise.resolve([])) + + // Mock providerSettingsManager.activateProfile + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "new-profile", + id: "new-profile-id", + apiProvider: "anthropic", + }) + + // Mock providerSettingsManager.listConfig + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" }, + ]) + + // Switch provider profile + await provider.activateProviderProfile({ name: "new-profile" }) + + // Verify task history was updated with new provider profile + expect(updateTaskHistorySpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: mockTask.taskId, + apiConfigName: "new-profile", + }), + ) + + // Verify task's setTaskApiConfigName was called + expect(mockTask.setTaskApiConfigName).toHaveBeenCalledWith("new-profile") + }) + + it("should update task's taskApiConfigName property when switching profiles", async () => { + // Create a mock task with initial profile + const mockTask = { + taskId: "test-task-id", + _taskApiConfigName: "default-profile", + setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) { + this._taskApiConfigName = name + }), + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } + + // Add task to provider stack + await provider.addClineToStack(mockTask as any) + + // Mock getGlobalState to return task history + vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + { + id: mockTask.taskId, + ts: Date.now(), + task: "Test task", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + }, + ]) + + // Mock updateTaskHistory + vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => Promise.resolve([])) + + // Mock providerSettingsManager.activateProfile + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "new-profile", + id: "new-profile-id", + apiProvider: "openrouter", + }) + + // Mock providerSettingsManager.listConfig + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-profile-id", apiProvider: "openrouter" }, + ]) + + // Switch provider profile + await provider.activateProviderProfile({ name: "new-profile" }) + + // Verify task's _taskApiConfigName property was updated + expect(mockTask._taskApiConfigName).toBe("new-profile") + }) + + it("should update in-memory task profile even if task history item does not exist yet", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const mockTask = { + taskId: "test-task-id", + _taskApiConfigName: "default-profile", + setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) { + this._taskApiConfigName = name + }), + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } + + await provider.addClineToStack(mockTask as any) + + // No history item exists yet + vi.spyOn(provider as any, "getGlobalState").mockReturnValue([]) + + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementation(() => Promise.resolve([])) + + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "new-profile", + id: "new-profile-id", + apiProvider: "openrouter", + }) + + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-profile-id", apiProvider: "openrouter" }, + ]) + + await provider.activateProviderProfile({ name: "new-profile" }) + + // In-memory should still update, even without a history item. + expect(mockTask._taskApiConfigName).toBe("new-profile") + // No history item => no updateTaskHistory call. + expect(updateTaskHistorySpy).not.toHaveBeenCalled() + }) + }) + + describe("createTaskWithHistoryItem", () => { + it("should restore provider profile from history item when reopening task", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Create a history item with saved provider profile + const historyItem: HistoryItem = { + id: "test-task-id", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 200, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.001, + mode: "code", + apiConfigName: "saved-profile", // Saved provider profile + } + + // Mock activateProviderProfile to track calls + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") + .mockResolvedValue(undefined) + + // Mock providerSettingsManager.listConfig + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "saved-profile", id: "saved-profile-id", apiProvider: "anthropic" }, + ]) + + // Initialize task with history item + await provider.createTaskWithHistoryItem(historyItem) + + // Verify provider profile was restored via activateProviderProfile (restore-only: don't persist mode config) + expect(activateProviderProfileSpy).toHaveBeenCalledWith( + { name: "saved-profile" }, + { persistModeConfig: false, persistTaskHistory: false }, + ) + }) + + it("should use current profile if history item has no saved apiConfigName", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Create a history item without saved provider profile + const historyItem: HistoryItem = { + id: "test-task-id", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 200, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.001, + // No apiConfigName field + } + + // Mock activateProviderProfile to track calls + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") + .mockResolvedValue(undefined) + + // Initialize task with history item + await provider.createTaskWithHistoryItem(historyItem) + + // Verify activateProviderProfile was NOT called for apiConfigName restoration + // (it might be called for mode-based config, but not for direct apiConfigName) + const callsForApiConfigName = activateProviderProfileSpy.mock.calls.filter( + (call) => call[0] && "name" in call[0] && call[0].name === historyItem.apiConfigName, + ) + expect(callsForApiConfigName.length).toBe(0) + }) + + it("should override mode-based config with task's apiConfigName", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Create a history item with both mode and apiConfigName + const historyItem: HistoryItem = { + id: "test-task-id", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 200, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.001, + mode: "architect", // Mode has a different preferred profile + apiConfigName: "task-specific-profile", // Task's actual profile + } + + // Track all activateProviderProfile calls + const activateCalls: string[] = [] + vi.spyOn(provider, "activateProviderProfile").mockImplementation(async (args) => { + if ("name" in args) { + activateCalls.push(args.name) + } + }) + + // Mock providerSettingsManager methods + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("mode-config-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "mode-preferred-profile", id: "mode-config-id", apiProvider: "anthropic" }, + { name: "task-specific-profile", id: "task-profile-id", apiProvider: "openai" }, + ]) + + // Initialize task with history item + await provider.createTaskWithHistoryItem(historyItem) + + // Verify task's apiConfigName was activated LAST (overriding mode-based config) + expect(activateCalls[activateCalls.length - 1]).toBe("task-specific-profile") + }) + + it("should handle missing provider profile gracefully", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Create a history item with a provider profile that no longer exists + const historyItem: HistoryItem = { + id: "test-task-id", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 200, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.001, + apiConfigName: "deleted-profile", // Profile that doesn't exist + } + + // Mock providerSettingsManager.listConfig to return empty (profile doesn't exist) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + + // Mock log to verify warning is logged + const logSpy = vi.spyOn(provider, "log") + + // Initialize task with history item - should not throw + await expect(provider.createTaskWithHistoryItem(historyItem)).resolves.not.toThrow() + + // Verify a warning was logged + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Provider profile 'deleted-profile' from history no longer exists"), + ) + }) + }) + + describe("Task metadata persistence", () => { + it("should include apiConfigName in task metadata when saving", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Create a mock task with provider profile + const mockTask = { + taskId: "test-task-id", + _taskApiConfigName: "test-profile", + setTaskApiConfigName: vi.fn(), + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } + + // Mock getGlobalState to return task history with our task + vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + { + id: mockTask.taskId, + ts: Date.now(), + task: "Test task", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + }, + ]) + + // Mock updateTaskHistory to capture the updated history item + let updatedHistoryItem: any + vi.spyOn(provider, "updateTaskHistory").mockImplementation((item) => { + updatedHistoryItem = item + return Promise.resolve([item]) + }) + + // Add task to provider stack + await provider.addClineToStack(mockTask as any) + + // Mock providerSettingsManager.activateProfile + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "new-profile", + id: "new-profile-id", + apiProvider: "anthropic", + }) + + // Mock providerSettingsManager.listConfig + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" }, + ]) + + // Trigger a profile switch + await provider.activateProviderProfile({ name: "new-profile" }) + + // Verify apiConfigName was included in the updated history item + expect(updatedHistoryItem).toBeDefined() + expect(updatedHistoryItem.apiConfigName).toBe("new-profile") + }) + }) + + describe("Multiple workspaces isolation", () => { + it("should preserve task profile when switching profiles in another workspace", async () => { + // This test verifies that each task retains its designated provider profile + // so that switching profiles in one workspace doesn't alter other tasks + + await provider.resolveWebviewView(mockWebviewView) + + // Create task 1 with profile A + const task1 = { + taskId: "task-1", + _taskApiConfigName: "profile-a", + setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) { + this._taskApiConfigName = name + }), + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } + + // Create task 2 with profile B + const task2 = { + taskId: "task-2", + _taskApiConfigName: "profile-b", + setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) { + this._taskApiConfigName = name + }), + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } + + // Add task 1 to stack + await provider.addClineToStack(task1 as any) + + // Mock getGlobalState to return task history for both tasks + const taskHistory = [ + { + id: "task-1", + ts: Date.now(), + task: "Task 1", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + apiConfigName: "profile-a", + }, + { + id: "task-2", + ts: Date.now(), + task: "Task 2", + number: 2, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + apiConfigName: "profile-b", + }, + ] + + vi.spyOn(provider as any, "getGlobalState").mockReturnValue(taskHistory) + + // Mock updateTaskHistory + vi.spyOn(provider, "updateTaskHistory").mockImplementation((item) => { + const index = taskHistory.findIndex((h) => h.id === item.id) + if (index >= 0) { + taskHistory[index] = { ...taskHistory[index], ...item } + } + return Promise.resolve(taskHistory) + }) + + // Mock providerSettingsManager.activateProfile + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "profile-c", + id: "profile-c-id", + apiProvider: "anthropic", + }) + + // Mock providerSettingsManager.listConfig + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "profile-a", id: "profile-a-id", apiProvider: "anthropic" }, + { name: "profile-b", id: "profile-b-id", apiProvider: "openai" }, + { name: "profile-c", id: "profile-c-id", apiProvider: "anthropic" }, + ]) + + // Switch task 1's profile to profile C + await provider.activateProviderProfile({ name: "profile-c" }) + + // Verify task 1's profile was updated + expect(task1._taskApiConfigName).toBe("profile-c") + expect(taskHistory[0].apiConfigName).toBe("profile-c") + + // Verify task 2's profile remains unchanged + expect(taskHistory[1].apiConfigName).toBe("profile-b") + }) + }) + + describe("Error handling", () => { + it("should handle errors gracefully when saving profile fails", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Create a mock task + const mockTask = { + taskId: "test-task-id", + _taskApiConfigName: "default-profile", + setTaskApiConfigName: vi.fn(), + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } + + // Add task to provider stack + await provider.addClineToStack(mockTask as any) + + // Mock getGlobalState + vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + { + id: mockTask.taskId, + ts: Date.now(), + task: "Test task", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + }, + ]) + + // Mock updateTaskHistory to throw error + vi.spyOn(provider, "updateTaskHistory").mockRejectedValue(new Error("Save failed")) + + // Mock providerSettingsManager.activateProfile + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "new-profile", + id: "new-profile-id", + apiProvider: "anthropic", + }) + + // Mock providerSettingsManager.listConfig + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" }, + ]) + + // Mock log to verify error is logged + const logSpy = vi.spyOn(provider, "log") + + // Switch provider profile - should not throw + await expect(provider.activateProviderProfile({ name: "new-profile" })).resolves.not.toThrow() + + // Verify error was logged + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to persist provider profile switch")) + }) + + it("should handle null/undefined apiConfigName gracefully", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Create a history item with null apiConfigName + const historyItem: HistoryItem = { + id: "test-task-id", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 200, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.001, + apiConfigName: null as any, // Invalid apiConfigName + } + + // Mock activateProviderProfile to track calls + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") + .mockResolvedValue(undefined) + + // Initialize task with history item - should not throw + await expect(provider.createTaskWithHistoryItem(historyItem)).resolves.not.toThrow() + + // Verify activateProviderProfile was not called with null + expect(activateProviderProfileSpy).not.toHaveBeenCalledWith({ name: null }) + }) + }) + + describe("Profile restoration with activateProfile failure", () => { + it("should continue task restoration even if activateProviderProfile fails", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Create a history item with saved provider profile + const historyItem: HistoryItem = { + id: "test-task-id", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 200, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.001, + apiConfigName: "failing-profile", + } + + // Mock providerSettingsManager.listConfig to return the profile + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "failing-profile", id: "failing-profile-id", apiProvider: "anthropic" }, + ]) + + // Mock activateProviderProfile to throw error + vi.spyOn(provider, "activateProviderProfile").mockRejectedValue(new Error("Activation failed")) + + // Mock log to verify error is logged + const logSpy = vi.spyOn(provider, "log") + + // Initialize task with history item - should not throw even though activation fails + await expect(provider.createTaskWithHistoryItem(historyItem)).resolves.not.toThrow() + + // Verify error was logged + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore API configuration 'failing-profile' for task"), + ) + }) + }) +}) diff --git a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts index 9b3f94f309..5aa2ea2c63 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts @@ -49,6 +49,7 @@ function makeProviderStub() { rooIgnoreController: { getInstructions: () => undefined }, }), getMcpHub: () => undefined, + getSkillsManager: () => undefined, // State must enable browser tool and provide apiConfiguration getState: async () => ({ apiConfiguration: { diff --git a/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts index a0687d6cc1..2b2b0f78b1 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts @@ -55,6 +55,10 @@ describe("webviewMessageHandler - checkpoint operations", () => { contextProxy: { globalStorageUri: { fsPath: "/test/storage" }, }, + getState: vi.fn().mockResolvedValue({ + maxImageFileSize: 5, + maxTotalImageSize: 20, + }), } }) @@ -124,7 +128,7 @@ describe("webviewMessageHandler - checkpoint operations", () => { operation: "edit", editData: { editedContent: "Edited checkpoint message", - images: undefined, + images: [], apiConversationHistoryIndex: 0, }, }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts index 5b89c723d4..2f11281d2b 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -71,6 +71,10 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { globalStorageUri: { fsPath: "/mock/storage" }, }, log: vi.fn(), + getState: vi.fn().mockResolvedValue({ + maxImageFileSize: 5, + maxTotalImageSize: 20, + }), } as unknown as ClineProvider }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts new file mode 100644 index 0000000000..277e56626a --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts @@ -0,0 +1,130 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as os from "os" + +// Must mock dependencies before importing the handler module. +vi.mock("../../../api/providers/fetchers/modelCache") + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +vi.mock("vscode", () => ({ + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], + }, +})) + +// Mock imageHelpers - use actual implementations for functions that need real file access +vi.mock("../../tools/helpers/imageHelpers", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + validateImageForProcessing: vi.fn().mockResolvedValue({ isValid: true, sizeInMB: 0.001 }), + ImageMemoryTracker: vi.fn().mockImplementation(() => ({ + getTotalMemoryUsed: vi.fn().mockReturnValue(0), + addMemoryUsage: vi.fn(), + })), + } +}) + +describe("webviewMessageHandler - image mentions (integration)", () => { + it("resolves image mentions for newTask and passes images to createTask", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-")) + try { + const imgBytes = Buffer.from("png-bytes") + await fs.writeFile(path.join(tmpRoot, "cat.png"), imgBytes) + + const mockProvider = { + cwd: tmpRoot, + getCurrentTask: vi.fn().mockReturnValue(undefined), + createTask: vi.fn().mockResolvedValue(undefined), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + getState: vi.fn().mockResolvedValue({ + maxImageFileSize: 5, + maxTotalImageSize: 20, + }), + } as unknown as ClineProvider + + await webviewMessageHandler(mockProvider, { + type: "newTask", + text: "Please look at @/cat.png", + images: [], + } as any) + + expect(mockProvider.createTask).toHaveBeenCalledWith("Please look at @/cat.png", [ + `data:image/png;base64,${imgBytes.toString("base64")}`, + ]) + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }) + } + }) + + it("resolves image mentions for askResponse and passes images to handleWebviewAskResponse", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-")) + try { + const imgBytes = Buffer.from("jpg-bytes") + await fs.writeFile(path.join(tmpRoot, "cat.jpg"), imgBytes) + + const handleWebviewAskResponse = vi.fn() + const mockProvider = { + cwd: tmpRoot, + getCurrentTask: vi.fn().mockReturnValue({ + cwd: tmpRoot, + handleWebviewAskResponse, + }), + getState: vi.fn().mockResolvedValue({ + maxImageFileSize: 5, + maxTotalImageSize: 20, + }), + } as unknown as ClineProvider + + await webviewMessageHandler(mockProvider, { + type: "askResponse", + askResponse: "messageResponse", + text: "Please look at @/cat.jpg", + images: [], + } as any) + + expect(handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Please look at @/cat.jpg", [ + `data:image/jpeg;base64,${imgBytes.toString("base64")}`, + ]) + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }) + } + }) + + it("resolves gif image mentions (matching read_file behavior)", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-")) + try { + const imgBytes = Buffer.from("gif-bytes") + await fs.writeFile(path.join(tmpRoot, "animation.gif"), imgBytes) + + const mockProvider = { + cwd: tmpRoot, + getCurrentTask: vi.fn().mockReturnValue(undefined), + createTask: vi.fn().mockResolvedValue(undefined), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + getState: vi.fn().mockResolvedValue({ + maxImageFileSize: 5, + maxTotalImageSize: 20, + }), + } as unknown as ClineProvider + + await webviewMessageHandler(mockProvider, { + type: "newTask", + text: "See @/animation.gif", + images: [], + } as any) + + expect(mockProvider.createTask).toHaveBeenCalledWith("See @/animation.gif", [ + `data:image/gif;base64,${imgBytes.toString("base64")}`, + ]) + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts new file mode 100644 index 0000000000..82f4d765ab --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts @@ -0,0 +1,297 @@ +// npx vitest core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts + +import type { Mock } from "vitest" + +// Mock dependencies - must come before imports +vi.mock("../../../services/search/file-search") +vi.mock("../../ignore/RooIgnoreController") + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" +import { searchWorkspaceFiles } from "../../../services/search/file-search" +import { RooIgnoreController } from "../../ignore/RooIgnoreController" + +const mockSearchWorkspaceFiles = searchWorkspaceFiles as Mock + +vi.mock("vscode", () => ({ + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], + }, +})) + +describe("webviewMessageHandler - searchFiles with RooIgnore filtering", () => { + let mockClineProvider: ClineProvider + let mockFilterPaths: Mock + let mockDispose: Mock + + beforeEach(() => { + vi.clearAllMocks() + + // Spy on the mock RooIgnoreController prototype methods + mockFilterPaths = vi.fn() + mockDispose = vi.fn() + + // Override the filterPaths method on the prototype + ;(RooIgnoreController.prototype as any).filterPaths = mockFilterPaths + ;(RooIgnoreController.prototype as any).initialize = vi.fn().mockResolvedValue(undefined) + ;(RooIgnoreController.prototype as any).dispose = mockDispose + + // Create mock ClineProvider + mockClineProvider = { + getState: vi.fn(), + postMessageToWebview: vi.fn(), + getCurrentTask: vi.fn(), + cwd: "/mock/workspace", + } as unknown as ClineProvider + }) + + it("should filter results using RooIgnoreController when showRooIgnoredFiles is false", async () => { + // Setup mock results from file search + const mockResults = [ + { path: "src/index.ts", type: "file" as const, label: "index.ts" }, + { path: "secrets/config.json", type: "file" as const, label: "config.json" }, + { path: "src/utils.ts", type: "file" as const, label: "utils.ts" }, + ] + mockSearchWorkspaceFiles.mockResolvedValue(mockResults) + + // Setup state with showRooIgnoredFiles = false + ;(mockClineProvider.getState as Mock).mockResolvedValue({ + showRooIgnoredFiles: false, + }) + + // Setup filter to exclude secrets folder + mockFilterPaths.mockReturnValue(["src/index.ts", "src/utils.ts"]) + + // No current task, so temporary controller will be created + ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null) + + await webviewMessageHandler(mockClineProvider, { + type: "searchFiles", + query: "index", + requestId: "test-request-123", + }) + + // Verify filterPaths was called with all result paths + expect(mockFilterPaths).toHaveBeenCalledWith(["src/index.ts", "secrets/config.json", "src/utils.ts"]) + + // Verify filtered results were sent to webview + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "fileSearchResults", + results: [ + { path: "src/index.ts", type: "file", label: "index.ts" }, + { path: "src/utils.ts", type: "file", label: "utils.ts" }, + ], + requestId: "test-request-123", + }) + }) + + it("should not filter results when showRooIgnoredFiles is true", async () => { + // Setup mock results from file search + const mockResults = [ + { path: "src/index.ts", type: "file" as const, label: "index.ts" }, + { path: "secrets/config.json", type: "file" as const, label: "config.json" }, + ] + mockSearchWorkspaceFiles.mockResolvedValue(mockResults) + + // Setup state with showRooIgnoredFiles = true + ;(mockClineProvider.getState as Mock).mockResolvedValue({ + showRooIgnoredFiles: true, + }) + + // No current task + ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null) + + await webviewMessageHandler(mockClineProvider, { + type: "searchFiles", + query: "index", + requestId: "test-request-456", + }) + + // Verify filterPaths was NOT called + expect(mockFilterPaths).not.toHaveBeenCalled() + + // Verify all results were sent to webview (unfiltered) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "fileSearchResults", + results: mockResults, + requestId: "test-request-456", + }) + }) + + it("should use existing RooIgnoreController from current task", async () => { + // Setup mock results from file search + const mockResults = [ + { path: "src/index.ts", type: "file" as const, label: "index.ts" }, + { path: "private/secret.ts", type: "file" as const, label: "secret.ts" }, + ] + mockSearchWorkspaceFiles.mockResolvedValue(mockResults) + + // Setup state with showRooIgnoredFiles = false + ;(mockClineProvider.getState as Mock).mockResolvedValue({ + showRooIgnoredFiles: false, + }) + + // Create a mock task with its own RooIgnoreController + const taskFilterPaths = vi.fn().mockReturnValue(["src/index.ts"]) + const taskRooIgnoreController = { + filterPaths: taskFilterPaths, + initialize: vi.fn(), + } + ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue({ + taskId: "test-task-id", + rooIgnoreController: taskRooIgnoreController, + }) + + await webviewMessageHandler(mockClineProvider, { + type: "searchFiles", + query: "index", + requestId: "test-request-789", + }) + + // Verify the task's controller was used (not the prototype) + expect(taskFilterPaths).toHaveBeenCalledWith(["src/index.ts", "private/secret.ts"]) + + // Verify filtered results were sent to webview + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "fileSearchResults", + results: [{ path: "src/index.ts", type: "file", label: "index.ts" }], + requestId: "test-request-789", + }) + }) + + it("should handle error when no workspace path is available", async () => { + // Create provider without cwd + mockClineProvider = { + ...mockClineProvider, + cwd: undefined, + getCurrentTask: vi.fn().mockReturnValue(null), + } as unknown as ClineProvider + + await webviewMessageHandler(mockClineProvider, { + type: "searchFiles", + query: "test", + requestId: "test-request-error", + }) + + // Verify error response was sent + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "fileSearchResults", + results: [], + requestId: "test-request-error", + error: "No workspace path available", + }) + }) + + it("should handle errors from searchWorkspaceFiles", async () => { + mockSearchWorkspaceFiles.mockRejectedValue(new Error("File search failed")) + + // Setup state + ;(mockClineProvider.getState as Mock).mockResolvedValue({ + showRooIgnoredFiles: false, + }) + ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null) + + await webviewMessageHandler(mockClineProvider, { + type: "searchFiles", + query: "test", + requestId: "test-request-fail", + }) + + // Verify error response was sent + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "fileSearchResults", + results: [], + error: "File search failed", + requestId: "test-request-fail", + }) + }) + + it("should default showRooIgnoredFiles to false when state is null", async () => { + // Setup mock results from file search + const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }] + mockSearchWorkspaceFiles.mockResolvedValue(mockResults) + + // Setup state to return null + ;(mockClineProvider.getState as Mock).mockResolvedValue(null) + + // Setup filter to return all paths (no filtering) + mockFilterPaths.mockReturnValue(["src/index.ts"]) + + // No current task + ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null) + + await webviewMessageHandler(mockClineProvider, { + type: "searchFiles", + query: "index", + requestId: "test-request-default", + }) + + // Verify filterPaths was called (showRooIgnoredFiles defaults to false) + expect(mockFilterPaths).toHaveBeenCalled() + }) + + it("should dispose temporary RooIgnoreController after use", async () => { + // Setup mock results from file search + const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }] + mockSearchWorkspaceFiles.mockResolvedValue(mockResults) + + // Setup state + ;(mockClineProvider.getState as Mock).mockResolvedValue({ + showRooIgnoredFiles: false, + }) + + // Setup filter + mockFilterPaths.mockReturnValue(["src/index.ts"]) + + // No current task, so temporary controller will be created and should be disposed + ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null) + + await webviewMessageHandler(mockClineProvider, { + type: "searchFiles", + query: "index", + requestId: "test-request-dispose", + }) + + // Verify dispose was called on the temporary controller + expect(mockDispose).toHaveBeenCalled() + }) + + it("should not dispose controller from current task", async () => { + // Setup mock results from file search + const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }] + mockSearchWorkspaceFiles.mockResolvedValue(mockResults) + + // Setup state + ;(mockClineProvider.getState as Mock).mockResolvedValue({ + showRooIgnoredFiles: false, + }) + + // Create a mock task with its own RooIgnoreController + const taskFilterPaths = vi.fn().mockReturnValue(["src/index.ts"]) + const taskDispose = vi.fn() + const taskRooIgnoreController = { + filterPaths: taskFilterPaths, + initialize: vi.fn(), + dispose: taskDispose, + } + ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue({ + taskId: "test-task-id", + rooIgnoreController: taskRooIgnoreController, + }) + + await webviewMessageHandler(mockClineProvider, { + type: "searchFiles", + query: "index", + requestId: "test-request-no-dispose", + }) + + // Verify dispose was NOT called on the task's controller + expect(taskDispose).not.toHaveBeenCalled() + // Verify the prototype dispose was also not called + expect(mockDispose).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 66423dd379..3e2a9efddd 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -117,6 +117,15 @@ vi.mock("../../../utils/fs") vi.mock("../../../utils/path") vi.mock("../../../utils/globalContext") +vi.mock("../../mentions/resolveImageMentions", () => ({ + resolveImageMentions: vi.fn(async ({ text, images }: { text: string; images?: string[] }) => ({ + text, + images: [...(images ?? []), "data:image/png;base64,from-mention"], + })), +})) + +import { resolveImageMentions } from "../../mentions/resolveImageMentions" + describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { vi.clearAllMocks() @@ -159,6 +168,37 @@ describe("webviewMessageHandler - requestLmStudioModels", () => { }) }) +describe("webviewMessageHandler - image mentions", () => { + beforeEach(() => { + vi.clearAllMocks() + mockClineProvider.getState = vi.fn().mockResolvedValue({ + maxImageFileSize: 5, + maxTotalImageSize: 20, + }) + }) + + it("should resolve image mentions for askResponse payloads", async () => { + const mockHandleWebviewAskResponse = vi.fn() + vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ + cwd: "/mock/workspace", + rooIgnoreController: undefined, + handleWebviewAskResponse: mockHandleWebviewAskResponse, + } as any) + + await webviewMessageHandler(mockClineProvider, { + type: "askResponse", + askResponse: "messageResponse", + text: "See @/img.png", + images: [], + }) + + expect(vi.mocked(resolveImageMentions)).toHaveBeenCalled() + expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "See @/img.png", [ + "data:image/png;base64,from-mention", + ]) + }) +}) + describe("webviewMessageHandler - requestOllamaModels", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index e79aa70728..341ba48451 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -27,6 +27,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web language, maxReadFileLine, maxConcurrentFileReads, + enableSubfolderRules, } = await provider.getState() // Check experiment to determine which diff strategy to use @@ -93,12 +94,16 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web maxConcurrentFileReads: maxConcurrentFileReads ?? 5, todoListEnabled: apiConfiguration?.todoListEnabled ?? true, useAgentRules: vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, + enableSubfolderRules: enableSubfolderRules ?? false, newTaskRequireTodos: vscode.workspace .getConfiguration(Package.name) .get("newTaskRequireTodos", false), toolProtocol, isStealthModel: modelInfo?.isStealthModel, }, + undefined, // todoList + undefined, // modelId + provider.getSkillsManager(), ) return systemPrompt diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 544b723f93..0df014b49a 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -54,6 +54,8 @@ import { exportSettings, importSettingsWithFeedback } from "../config/importExpo import { getOpenAiModels } from "../../api/providers/openai" import { getVsCodeLmModels } from "../../api/providers/vscode-lm" import { openMention } from "../mentions" +import { resolveImageMentions } from "../mentions/resolveImageMentions" +import { RooIgnoreController } from "../ignore/RooIgnoreController" import { getWorkspacePath } from "../../utils/path" import { Mode, defaultModeSlug } from "../../shared/modes" import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" @@ -79,6 +81,26 @@ export const webviewMessageHandler = async ( const getCurrentCwd = () => { return provider.getCurrentTask()?.cwd || provider.cwd } + + /** + * Resolves image file mentions in incoming messages. + * Matches read_file behavior: respects size limits and model capabilities. + */ + const resolveIncomingImages = async (payload: { text?: string; images?: string[] }) => { + const text = payload.text ?? "" + const images = payload.images + const currentTask = provider.getCurrentTask() + const state = await provider.getState() + const resolved = await resolveImageMentions({ + text, + images, + cwd: getCurrentCwd(), + rooIgnoreController: currentTask?.rooIgnoreController, + maxImageFileSize: state.maxImageFileSize, + maxTotalImageSize: state.maxTotalImageSize, + }) + return resolved + } /** * Shared utility to find message indices based on timestamp. * When multiple messages share the same timestamp (e.g., after condense), @@ -505,7 +527,8 @@ export const webviewMessageHandler = async ( // agentically running promises in old instance don't affect our new // task. This essentially creates a fresh slate for the new task. try { - await provider.createTask(message.text, message.images) + const resolved = await resolveIncomingImages({ text: message.text, images: message.images }) + await provider.createTask(resolved.text, resolved.images) // Task created successfully - notify the UI to reset await provider.postMessageToWebview({ type: "invoke", invoke: "newChat" }) } catch (error) { @@ -522,7 +545,12 @@ export const webviewMessageHandler = async ( break case "askResponse": - provider.getCurrentTask()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images) + { + const resolved = await resolveIncomingImages({ text: message.text, images: message.images }) + provider + .getCurrentTask() + ?.handleWebviewAskResponse(message.askResponse!, resolved.text, resolved.images) + } break case "updateSettings": @@ -1708,12 +1736,39 @@ export const webviewMessageHandler = async ( 20, // Use default limit, as filtering is now done in the backend ) - // Send results back to webview - await provider.postMessageToWebview({ - type: "fileSearchResults", - results, - requestId: message.requestId, - }) + // Get the RooIgnoreController from the current task, or create a new one + const currentTask = provider.getCurrentTask() + let rooIgnoreController = currentTask?.rooIgnoreController + let tempController: RooIgnoreController | undefined + + // If no current task or no controller, create a temporary one + if (!rooIgnoreController) { + tempController = new RooIgnoreController(workspacePath) + await tempController.initialize() + rooIgnoreController = tempController + } + + try { + // Get showRooIgnoredFiles setting from state + const { showRooIgnoredFiles = false } = (await provider.getState()) ?? {} + + // Filter results using RooIgnoreController if showRooIgnoredFiles is false + let filteredResults = results + if (!showRooIgnoredFiles && rooIgnoreController) { + const allowedPaths = rooIgnoreController.filterPaths(results.map((r) => r.path)) + filteredResults = results.filter((r) => allowedPaths.includes(r.path)) + } + + // Send results back to webview + await provider.postMessageToWebview({ + type: "fileSearchResults", + results: filteredResults, + requestId: message.requestId, + }) + } finally { + // Dispose temporary controller to prevent resource leak + tempController?.dispose() + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) @@ -1877,11 +1932,12 @@ export const webviewMessageHandler = async ( break case "editMessageConfirm": if (message.messageTs && message.text) { + const resolved = await resolveIncomingImages({ text: message.text, images: message.images }) await handleEditMessageConfirm( message.messageTs, - message.text, + resolved.text, message.restoreCheckpoint, - message.images, + resolved.images, ) } break @@ -2218,25 +2274,6 @@ export const webviewMessageHandler = async ( }) } break - case "humanRelayResponse": - if (message.requestId && message.text) { - vscode.commands.executeCommand(getCommand("handleHumanRelayResponse"), { - requestId: message.requestId, - text: message.text, - cancelled: false, - }) - } - break - - case "humanRelayCancel": - if (message.requestId) { - vscode.commands.executeCommand(getCommand("handleHumanRelayResponse"), { - requestId: message.requestId, - cancelled: true, - }) - } - break - case "telemetrySetting": { const telemetrySetting = message.text as TelemetrySetting const previousSetting = getGlobalState("telemetrySetting") || "unset" @@ -2384,6 +2421,12 @@ export const webviewMessageHandler = async ( break } + case "clearCloudAuthSkipModel": { + // Clear the flag that indicates auth completed without model selection + await provider.context.globalState.update("roo-auth-skip-model", undefined) + await provider.postStateToWebview() + break + } case "switchOrganization": { try { const organizationId = message.organizationId ?? null @@ -3065,7 +3108,8 @@ export const webviewMessageHandler = async ( */ case "queueMessage": { - provider.getCurrentTask()?.messageQueueService.addMessage(message.text ?? "", message.images) + const resolved = await resolveIncomingImages({ text: message.text, images: message.images }) + provider.getCurrentTask()?.messageQueueService.addMessage(resolved.text, resolved.images) break } case "removeQueuedMessage": { diff --git a/src/esbuild.mjs b/src/esbuild.mjs index 68298eb3de..aabacfcee9 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -100,7 +100,10 @@ async function main() { plugins, entryPoints: ["extension.ts"], outfile: "dist/extension.js", - external: ["vscode", "esbuild"], + // global-agent must be external because it dynamically patches Node.js http/https modules + // which breaks when bundled. It needs access to the actual Node.js module instances. + // undici must be bundled because our VSIX is packaged with `--no-dependencies`. + external: ["vscode", "esbuild", "global-agent"], } /** diff --git a/src/extension.ts b/src/extension.ts index dcb941fa58..76f02af6de 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,7 @@ import { customToolRegistry } from "@roo-code/core" import "./utils/path" // Necessary to have access to String.prototype.toPosix. import { createOutputChannelLogger, createDualLogger } from "./utils/outputChannelLogger" +import { initializeNetworkProxy } from "./utils/networkProxy" import { Package } from "./shared/package" import { formatLanguage } from "./shared/language" @@ -68,6 +69,11 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(outputChannel) outputChannel.appendLine(`${Package.name} extension activated - ${JSON.stringify(Package)}`) + // Initialize network proxy configuration early, before any network requests. + // When proxyUrl is configured, all HTTP/HTTPS traffic will be routed through it. + // Only applied in debug mode (F5). + await initializeNetworkProxy(context, outputChannel) + // Set extension path for custom tool registry to find bundled esbuild customToolRegistry.setExtensionPath(context.extensionPath) @@ -96,7 +102,7 @@ export async function activate(context: vscode.ExtensionContext) { TerminalRegistry.initialize() // Initialize Claude Code OAuth manager for direct API access. - claudeCodeOAuthManager.initialize(context) + claudeCodeOAuthManager.initialize(context, (message) => outputChannel.appendLine(message)) // Get default commands from configuration. const defaultCommands = vscode.workspace.getConfiguration(Package.name).get("allowedCommands") || [] diff --git a/src/integrations/claude-code/__tests__/oauth.spec.ts b/src/integrations/claude-code/__tests__/oauth.spec.ts index 526ef2f6f7..7de75ec529 100644 --- a/src/integrations/claude-code/__tests__/oauth.spec.ts +++ b/src/integrations/claude-code/__tests__/oauth.spec.ts @@ -195,4 +195,41 @@ describe("Claude Code OAuth", () => { expect(CLAUDE_CODE_OAUTH_CONFIG.callbackPort).toBe(54545) }) }) + + describe("refresh token behavior", () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + test("refresh responses may omit refresh_token (should be tolerated)", async () => { + const { refreshAccessToken } = await import("../oauth") + + // Mock fetch to return a refresh response with no refresh_token + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + access_token: "new-access", + expires_in: 3600, + // refresh_token intentionally omitted + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + + vi.stubGlobal("fetch", mockFetch) + + const creds: ClaudeCodeCredentials = { + type: "claude" as const, + access_token: "old-access", + refresh_token: "old-refresh", + expired: new Date(Date.now() - 1000).toISOString(), + email: "test@example.com", + } + + const refreshed = await refreshAccessToken(creds) + expect(refreshed.access_token).toBe("new-access") + expect(refreshed.refresh_token).toBe("old-refresh") + expect(refreshed.email).toBe("test@example.com") + }) + }) }) diff --git a/src/integrations/claude-code/oauth.ts b/src/integrations/claude-code/oauth.ts index 036ad70b63..5d7a929e1c 100644 --- a/src/integrations/claude-code/oauth.ts +++ b/src/integrations/claude-code/oauth.ts @@ -31,12 +31,74 @@ export type ClaudeCodeCredentials = z.infer // Token response schema from Anthropic const tokenResponseSchema = z.object({ access_token: z.string(), - refresh_token: z.string(), + // Refresh responses may omit refresh_token (common OAuth behavior). When omitted, + // callers must preserve the existing refresh token. + refresh_token: z.string().min(1).optional(), expires_in: z.number(), email: z.string().optional(), token_type: z.string().optional(), }) +class ClaudeCodeOAuthTokenError extends Error { + public readonly status?: number + public readonly errorCode?: string + + constructor(message: string, opts?: { status?: number; errorCode?: string }) { + super(message) + this.name = "ClaudeCodeOAuthTokenError" + this.status = opts?.status + this.errorCode = opts?.errorCode + } + + public isLikelyInvalidGrant(): boolean { + if (this.errorCode && /invalid_grant/i.test(this.errorCode)) { + return true + } + if (this.status === 400 || this.status === 401 || this.status === 403) { + return /invalid_grant|revoked|expired|invalid refresh/i.test(this.message) + } + return false + } +} + +function parseOAuthErrorDetails(errorText: string): { errorCode?: string; errorMessage?: string } { + try { + const json: unknown = JSON.parse(errorText) + if (!json || typeof json !== "object") { + return {} + } + + const obj = json as Record + const errorField = obj.error + + const errorCode: string | undefined = + typeof errorField === "string" + ? errorField + : errorField && + typeof errorField === "object" && + typeof (errorField as Record).type === "string" + ? ((errorField as Record).type as string) + : undefined + + const errorDescription = obj.error_description + const errorMessageFromError = + errorField && typeof errorField === "object" ? (errorField as Record).message : undefined + + const errorMessage: string | undefined = + typeof errorDescription === "string" + ? errorDescription + : typeof errorMessageFromError === "string" + ? errorMessageFromError + : typeof obj.message === "string" + ? obj.message + : undefined + + return { errorCode, errorMessage } + } catch { + return {} + } +} + /** * Generates a cryptographically random PKCE code verifier * Must be 43-128 characters long using unreserved characters @@ -134,6 +196,11 @@ export async function exchangeCodeForTokens( const data = await response.json() const tokenResponse = tokenResponseSchema.parse(data) + if (!tokenResponse.refresh_token) { + // The access token is unusable without a refresh token for persistence. + throw new Error("Token exchange did not return a refresh_token") + } + // Calculate expiry time const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000) @@ -149,11 +216,11 @@ export async function exchangeCodeForTokens( /** * Refreshes the access token using the refresh token */ -export async function refreshAccessToken(refreshToken: string): Promise { +export async function refreshAccessToken(credentials: ClaudeCodeCredentials): Promise { const body = { grant_type: "refresh_token", client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId, - refresh_token: refreshToken, + refresh_token: credentials.refresh_token, } const response = await fetch(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint, { @@ -167,7 +234,12 @@ export async function refreshAccessToken(refreshToken: string): Promise void) | null = null + private refreshPromise: Promise | null = null private pendingAuth: { codeVerifier: string state: string server?: http.Server } | null = null + private log(message: string): void { + if (this.logFn) { + this.logFn(message) + } else { + console.log(message) + } + } + + private logError(message: string, error?: unknown): void { + const details = error instanceof Error ? error.message : error !== undefined ? String(error) : undefined + const full = details ? `${message} ${details}` : message + this.log(full) + console.error(full) + } + /** * Initialize the OAuth manager with VS Code extension context */ - initialize(context: ExtensionContext): void { + initialize(context: ExtensionContext, logFn?: (message: string) => void): void { this.context = context + this.logFn = logFn ?? null + } + + /** + * Force a refresh using the stored refresh token even if the access token is not expired. + * Useful when the server invalidates an access token early. + */ + async forceRefreshAccessToken(): Promise { + if (!this.credentials) { + await this.loadCredentials() + } + + if (!this.credentials) { + return null + } + + try { + // De-dupe concurrent refreshes + if (!this.refreshPromise) { + const prevRefreshToken = this.credentials.refresh_token + this.log(`[claude-code-oauth] Forcing token refresh (expired=${this.credentials.expired})...`) + this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { + const rotated = newCreds.refresh_token !== prevRefreshToken + this.log( + `[claude-code-oauth] Forced refresh response received (expires_in≈${Math.round( + (new Date(newCreds.expired).getTime() - Date.now()) / 1000, + )}s, refresh_token_rotated=${rotated})`, + ) + return newCreds + }) + } + + const newCredentials = await this.refreshPromise + this.refreshPromise = null + await this.saveCredentials(newCredentials) + this.log(`[claude-code-oauth] Forced token persisted (expired=${newCredentials.expired})`) + return newCredentials.access_token + } catch (error) { + this.refreshPromise = null + this.logError("[claude-code-oauth] Failed to force refresh token:", error) + if (error instanceof ClaudeCodeOAuthTokenError && error.isLikelyInvalidGrant()) { + this.log("[claude-code-oauth] Refresh token appears invalid; clearing stored credentials") + await this.clearCredentials() + } + return null + } } /** @@ -231,7 +366,7 @@ export class ClaudeCodeOAuthManager { this.credentials = claudeCodeCredentialsSchema.parse(parsed) return this.credentials } catch (error) { - console.error("[claude-code-oauth] Failed to load credentials:", error) + this.logError("[claude-code-oauth] Failed to load credentials:", error) return null } } @@ -276,12 +411,36 @@ export class ClaudeCodeOAuthManager { // Check if token is expired and refresh if needed if (isTokenExpired(this.credentials)) { try { - const newCredentials = await refreshAccessToken(this.credentials.refresh_token) + // De-dupe concurrent refreshes + if (!this.refreshPromise) { + this.log( + `[claude-code-oauth] Access token expired (expired=${this.credentials.expired}). Refreshing...`, + ) + const prevRefreshToken = this.credentials.refresh_token + this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { + const rotated = newCreds.refresh_token !== prevRefreshToken + this.log( + `[claude-code-oauth] Refresh response received (expires_in≈${Math.round( + (new Date(newCreds.expired).getTime() - Date.now()) / 1000, + )}s, refresh_token_rotated=${rotated})`, + ) + return newCreds + }) + } + + const newCredentials = await this.refreshPromise + this.refreshPromise = null await this.saveCredentials(newCredentials) + this.log(`[claude-code-oauth] Token persisted (expired=${newCredentials.expired})`) } catch (error) { - console.error("[claude-code-oauth] Failed to refresh token:", error) - // Clear invalid credentials - await this.clearCredentials() + this.refreshPromise = null + this.logError("[claude-code-oauth] Failed to refresh token:", error) + + // Only clear secrets when the refresh token is clearly invalid/revoked. + if (error instanceof ClaudeCodeOAuthTokenError && error.isLikelyInvalidGrant()) { + this.log("[claude-code-oauth] Refresh token appears invalid; clearing stored credentials") + await this.clearCredentials() + } return null } } diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index c798f4b5ad..370bf0d377 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -199,7 +199,6 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { psTree(this.pid, async (err, children) => { if (!err) { const pids = children.map((p) => parseInt(p.PID)) - console.error(`[ExecaTerminalProcess#abort] SIGKILL children -> ${pids.join(", ")}`) for (const pid of pids) { try { diff --git a/src/package.json b/src/package.json index 75cfb77c68..5ea54c38be 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.37.0", + "version": "3.39.0", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", @@ -411,6 +411,24 @@ "type": "boolean", "default": false, "description": "%settings.debug.description%" + }, + "roo-cline.debugProxy.enabled": { + "type": "boolean", + "default": false, + "description": "%settings.debugProxy.enabled.description%", + "markdownDescription": "%settings.debugProxy.enabled.description%" + }, + "roo-cline.debugProxy.serverUrl": { + "type": "string", + "default": "http://127.0.0.1:8888", + "description": "%settings.debugProxy.serverUrl.description%", + "markdownDescription": "%settings.debugProxy.serverUrl.description%" + }, + "roo-cline.debugProxy.tlsInsecure": { + "type": "boolean", + "default": false, + "description": "%settings.debugProxy.tlsInsecure.description%", + "markdownDescription": "%settings.debugProxy.tlsInsecure.description%" } } } @@ -461,6 +479,7 @@ "fastest-levenshtein": "^1.0.16", "fzf": "^0.5.2", "get-folder-size": "^5.0.0", + "global-agent": "^3.0.0", "google-auth-library": "^9.15.1", "gray-matter": "^4.0.3", "i18next": "^25.0.0", @@ -503,6 +522,7 @@ "tmp": "^0.2.3", "tree-sitter-wasms": "^0.1.12", "turndown": "^7.2.0", + "undici": "^6.21.3", "uuid": "^11.1.0", "vscode-material-icons": "^0.1.1", "web-tree-sitter": "^0.25.6", diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index de3bb987cc..2781ed169c 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Temps màxim en segons per esperar les respostes de l'API (0 = sense temps d'espera, 1-3600s, per defecte: 600s). Es recomanen valors més alts per a proveïdors locals com LM Studio i Ollama que poden necessitar més temps de processament.", "settings.newTaskRequireTodos.description": "Requerir el paràmetre de tasques pendents quan es creïn noves tasques amb l'eina new_task", "settings.codeIndex.embeddingBatchSize.description": "La mida del lot per a operacions d'incrustació durant la indexació de codi. Ajusta això segons els límits del teu proveïdor d'API. Per defecte és 60.", - "settings.debug.description": "Activa el mode de depuració per mostrar botons addicionals per veure l'historial de conversa de l'API i els missatges de la interfície d'usuari com a JSON embellert en fitxers temporals." + "settings.debug.description": "Activa el mode de depuració per mostrar botons addicionals per veure l'historial de conversa de l'API i els missatges de la interfície d'usuari com a JSON embellert en fitxers temporals.", + "settings.debugProxy.enabled.description": "**Habilita el Debug Proxy** — Redirigeix totes les sol·licituds de xarxa sortints a través d'un proxy per a debugging MITM. Només està actiu quan s'executa en mode debug (F5).", + "settings.debugProxy.serverUrl.description": "URL del proxy (p. ex., `http://127.0.0.1:8888`). Només s'utilitza quan el **Debug Proxy** està habilitat.", + "settings.debugProxy.tlsInsecure.description": "Accepta certificats auto-signats del proxy. **Requerit per a la inspecció MITM.** ⚠️ Insegur — utilitza-ho només per a debugging local." } diff --git a/src/package.nls.de.json b/src/package.nls.de.json index d0f9184937..a77a253ef0 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Maximale Wartezeit in Sekunden auf API-Antworten (0 = kein Timeout, 1-3600s, Standard: 600s). Höhere Werte werden für lokale Anbieter wie LM Studio und Ollama empfohlen, die möglicherweise mehr Verarbeitungszeit benötigen.", "settings.newTaskRequireTodos.description": "Todos-Parameter beim Erstellen neuer Aufgaben mit dem new_task-Tool erfordern", "settings.codeIndex.embeddingBatchSize.description": "Die Batch-Größe für Embedding-Operationen während der Code-Indexierung. Passe dies an die Limits deines API-Anbieters an. Standard ist 60.", - "settings.debug.description": "Aktiviere den Debug-Modus, um zusätzliche Schaltflächen zum Anzeigen des API-Konversationsverlaufs und der UI-Nachrichten als formatiertes JSON in temporären Dateien anzuzeigen." + "settings.debug.description": "Aktiviere den Debug-Modus, um zusätzliche Schaltflächen zum Anzeigen des API-Konversationsverlaufs und der UI-Nachrichten als formatiertes JSON in temporären Dateien anzuzeigen.", + "settings.debugProxy.enabled.description": "**Debug-Proxy aktivieren** — Leite alle ausgehenden Netzwerkanfragen über einen Proxy für MITM-Debugging. Nur aktiv, wenn du im Debug-Modus (F5) läufst.", + "settings.debugProxy.serverUrl.description": "Proxy-URL (z. B. `http://127.0.0.1:8888`). Wird nur verwendet, wenn der **Debug-Proxy** aktiviert ist.", + "settings.debugProxy.tlsInsecure.description": "Akzeptiere selbstsignierte Zertifikate vom Proxy. **Erforderlich für MITM-Inspektion.** ⚠️ Unsicher – verwende das nur für lokales Debugging." } diff --git a/src/package.nls.es.json b/src/package.nls.es.json index e2ab5a95c8..a1c729080e 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Tiempo máximo en segundos de espera para las respuestas de la API (0 = sin tiempo de espera, 1-3600s, por defecto: 600s). Se recomiendan valores más altos para proveedores locales como LM Studio y Ollama que puedan necesitar más tiempo de procesamiento.", "settings.newTaskRequireTodos.description": "Requerir el parámetro todos al crear nuevas tareas con la herramienta new_task", "settings.codeIndex.embeddingBatchSize.description": "El tamaño del lote para operaciones de embedding durante la indexación de código. Ajusta esto según los límites de tu proveedor de API. Por defecto es 60.", - "settings.debug.description": "Activa el modo de depuración para mostrar botones adicionales para ver el historial de conversación de API y los mensajes de la interfaz de usuario como JSON embellecido en archivos temporales." + "settings.debug.description": "Activa el modo de depuración para mostrar botones adicionales para ver el historial de conversación de API y los mensajes de la interfaz de usuario como JSON embellecido en archivos temporales.", + "settings.debugProxy.enabled.description": "**Activar Debug Proxy** — Redirige todas las solicitudes de red salientes a través de un proxy para depuración MITM. Solo está activo cuando se ejecuta en modo depuración (F5).", + "settings.debugProxy.serverUrl.description": "URL del proxy (p. ej., `http://127.0.0.1:8888`). Solo se usa cuando **Debug Proxy** está activado.", + "settings.debugProxy.tlsInsecure.description": "Aceptar certificados autofirmados del proxy. **Necesario para la inspección MITM.** ⚠️ Inseguro: úsalo solo para depuración local." } diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index c725dbc298..2d009c0038 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Temps maximum en secondes d'attente pour les réponses de l'API (0 = pas de timeout, 1-3600s, par défaut : 600s). Des valeurs plus élevées sont recommandées pour les fournisseurs locaux comme LM Studio et Ollama qui peuvent nécessiter plus de temps de traitement.", "settings.newTaskRequireTodos.description": "Exiger le paramètre todos lors de la création de nouvelles tâches avec l'outil new_task", "settings.codeIndex.embeddingBatchSize.description": "La taille du lot pour les opérations d'embedding lors de l'indexation du code. Ajustez ceci selon les limites de votre fournisseur d'API. Par défaut, c'est 60.", - "settings.debug.description": "Active le mode debug pour afficher des boutons supplémentaires permettant de visualiser l'historique de conversation de l'API et les messages de l'interface utilisateur sous forme de JSON formaté dans des fichiers temporaires." + "settings.debug.description": "Active le mode debug pour afficher des boutons supplémentaires permettant de visualiser l'historique de conversation de l'API et les messages de l'interface utilisateur sous forme de JSON formaté dans des fichiers temporaires.", + "settings.debugProxy.enabled.description": "**Activer le Debug Proxy** — Redirige toutes les requêtes réseau sortantes via un proxy pour le debug MITM. Actif uniquement quand tu es en mode debug (F5).", + "settings.debugProxy.serverUrl.description": "URL du proxy (par ex. `http://127.0.0.1:8888`). Utilisée uniquement quand le **Debug Proxy** est activé.", + "settings.debugProxy.tlsInsecure.description": "Accepter les certificats auto-signés du proxy. **Requis pour l'inspection MITM.** ⚠️ Non sécurisé — à utiliser uniquement pour le debug local." } diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index 08ae5be0ea..c51f3ee95e 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "एपीआई प्रतिक्रियाओं की प्रतीक्षा करने के लिए सेकंड में अधिकतम समय (0 = कोई टाइमआउट नहीं, 1-3600s, डिफ़ॉल्ट: 600s)। एलएम स्टूडियो और ओलामा जैसे स्थानीय प्रदाताओं के लिए उच्च मानों की सिफारिश की जाती है जिन्हें अधिक प्रसंस्करण समय की आवश्यकता हो सकती है।", "settings.newTaskRequireTodos.description": "new_task टूल के साथ नए कार्य बनाते समय टूडू पैरामीटर की आवश्यकता होती है", "settings.codeIndex.embeddingBatchSize.description": "कोड इंडेक्सिंग के दौरान एम्बेडिंग ऑपरेशन के लिए बैच साइज़। इसे अपने API प्रदाता की सीमाओं के अनुसार समायोजित करें। डिफ़ॉल्ट 60 है।", - "settings.debug.description": "API conversation history और UI messages को temporary files में prettified JSON के रूप में देखने के लिए अतिरिक्त बटन दिखाने के लिए debug mode सक्षम करें।" + "settings.debug.description": "API conversation history और UI messages को temporary files में prettified JSON के रूप में देखने के लिए अतिरिक्त बटन दिखाने के लिए debug mode सक्षम करें।", + "settings.debugProxy.enabled.description": "**Debug Proxy सक्षम करो** — सभी आउटबाउंड network requests को MITM debugging के लिए proxy के ज़रिए route करो। सिर्फ तब active रहेगा जब तुम debug mode (F5) में चला रहे हो।", + "settings.debugProxy.serverUrl.description": "Proxy URL (जैसे `http://127.0.0.1:8888`)। सिर्फ तब इस्तेमाल होती है जब **Debug Proxy** enabled हो।", + "settings.debugProxy.tlsInsecure.description": "Proxy से आने वाले self-signed certificates accept करो। **MITM inspection के लिए ज़रूरी।** ⚠️ Insecure — सिर्फ local debugging के लिए इस्तेमाल करो।" } diff --git a/src/package.nls.id.json b/src/package.nls.id.json index 83410391cf..2a7607f3e7 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Waktu maksimum dalam detik untuk menunggu respons API (0 = tidak ada batas waktu, 1-3600s, default: 600s). Nilai yang lebih tinggi disarankan untuk penyedia lokal seperti LM Studio dan Ollama yang mungkin memerlukan lebih banyak waktu pemrosesan.", "settings.newTaskRequireTodos.description": "Memerlukan parameter todos saat membuat tugas baru dengan alat new_task", "settings.codeIndex.embeddingBatchSize.description": "Ukuran batch untuk operasi embedding selama pengindeksan kode. Sesuaikan ini berdasarkan batas penyedia API kamu. Default adalah 60.", - "settings.debug.description": "Aktifkan mode debug untuk menampilkan tombol tambahan untuk melihat riwayat percakapan API dan pesan UI sebagai JSON yang diformat dalam file sementara." + "settings.debug.description": "Aktifkan mode debug untuk menampilkan tombol tambahan untuk melihat riwayat percakapan API dan pesan UI sebagai JSON yang diformat dalam file sementara.", + "settings.debugProxy.enabled.description": "**Aktifkan Debug Proxy** — Arahkan semua permintaan jaringan keluar lewat proxy untuk debugging MITM. Hanya aktif saat kamu berjalan dalam mode debug (F5).", + "settings.debugProxy.serverUrl.description": "URL proxy (mis. `http://127.0.0.1:8888`). Hanya digunakan ketika **Debug Proxy** diaktifkan.", + "settings.debugProxy.tlsInsecure.description": "Terima sertifikat self-signed dari proxy. **Diperlukan untuk inspeksi MITM.** ⚠️ Tidak aman — gunakan hanya untuk debugging lokal." } diff --git a/src/package.nls.it.json b/src/package.nls.it.json index 1ddeb596cf..c94471355d 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Tempo massimo in secondi di attesa per le risposte API (0 = nessun timeout, 1-3600s, predefinito: 600s). Valori più alti sono consigliati per provider locali come LM Studio e Ollama che potrebbero richiedere più tempo di elaborazione.", "settings.newTaskRequireTodos.description": "Richiedere il parametro todos quando si creano nuove attività con lo strumento new_task", "settings.codeIndex.embeddingBatchSize.description": "La dimensione del batch per le operazioni di embedding durante l'indicizzazione del codice. Regola questo in base ai limiti del tuo provider API. Il valore predefinito è 60.", - "settings.debug.description": "Abilita la modalità debug per mostrare pulsanti aggiuntivi per visualizzare la cronologia delle conversazioni API e i messaggi dell'interfaccia utente come JSON formattato in file temporanei." + "settings.debug.description": "Abilita la modalità debug per mostrare pulsanti aggiuntivi per visualizzare la cronologia delle conversazioni API e i messaggi dell'interfaccia utente come JSON formattato in file temporanei.", + "settings.debugProxy.enabled.description": "**Abilita Debug Proxy** — Instrada tutte le richieste di rete in uscita tramite un proxy per il debugging MITM. Attivo solo quando esegui in modalità debug (F5).", + "settings.debugProxy.serverUrl.description": "URL del proxy (ad es. `http://127.0.0.1:8888`). Usato solo quando **Debug Proxy** è abilitato.", + "settings.debugProxy.tlsInsecure.description": "Accetta certificati autofirmati dal proxy. **Necessario per l'ispezione MITM.** ⚠️ Non sicuro — usalo solo per il debugging locale." } diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index 7fe1adac66..ff6040d773 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "API応答を待機する最大時間(秒)(0 = タイムアウトなし、1-3600秒、デフォルト: 600秒)。LM StudioやOllamaのような、より多くの処理時間を必要とする可能性のあるローカルプロバイダーには、より高い値が推奨されます。", "settings.newTaskRequireTodos.description": "new_taskツールで新しいタスクを作成する際にtodosパラメータを必須にする", "settings.codeIndex.embeddingBatchSize.description": "コードインデックス作成中のエンベディング操作のバッチサイズ。APIプロバイダーの制限に基づいてこれを調整してください。デフォルトは60です。", - "settings.debug.description": "デバッグモードを有効にして、API会話履歴とUIメッセージをフォーマットされたJSONとして一時ファイルで表示するための追加ボタンを表示します。" + "settings.debug.description": "デバッグモードを有効にして、API会話履歴とUIメッセージをフォーマットされたJSONとして一時ファイルで表示するための追加ボタンを表示します。", + "settings.debugProxy.enabled.description": "**Debug Proxy を有効化** — すべての送信ネットワーク要求を MITM デバッグのためにプロキシ経由でルーティングします。デバッグモード (F5) で実行しているときだけ有効です。", + "settings.debugProxy.serverUrl.description": "プロキシ URL(例: `http://127.0.0.1:8888`)。**Debug Proxy** が有効なときにだけ使用されます。", + "settings.debugProxy.tlsInsecure.description": "プロキシからの自己署名証明書を許可します。**MITM インスペクションに必須です。** ⚠️ 危険な設定なので、ローカルでのデバッグにだけ使用してください。" } diff --git a/src/package.nls.json b/src/package.nls.json index 0030d1ce7b..177b392f77 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Maximum time in seconds to wait for API responses (0 = no timeout, 1-3600s, default: 600s). Higher values are recommended for local providers like LM Studio and Ollama that may need more processing time.", "settings.newTaskRequireTodos.description": "Require todos parameter when creating new tasks with the new_task tool", "settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60.", - "settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files." + "settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files.", + "settings.debugProxy.enabled.description": "**Enable Debug Proxy** — Route all outbound network requests through a proxy for MITM debugging. Only active when running in debug mode (F5).", + "settings.debugProxy.serverUrl.description": "Proxy URL (e.g., `http://127.0.0.1:8888`). Only used when **Debug Proxy** is enabled.", + "settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging." } diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index ce471aa937..f0912835b8 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "API 응답을 기다리는 최대 시간(초) (0 = 시간 초과 없음, 1-3600초, 기본값: 600초). 더 많은 처리 시간이 필요할 수 있는 LM Studio 및 Ollama와 같은 로컬 공급자에게는 더 높은 값을 사용하는 것이 좋습니다.", "settings.newTaskRequireTodos.description": "new_task 도구로 새 작업을 생성할 때 todos 매개변수 필요", "settings.codeIndex.embeddingBatchSize.description": "코드 인덱싱 중 임베딩 작업의 배치 크기입니다. API 공급자의 제한에 따라 이를 조정하세요. 기본값은 60입니다.", - "settings.debug.description": "디버그 모드를 활성화하여 API 대화 기록과 UI 메시지를 임시 파일에 포맷된 JSON으로 보기 위한 추가 버튼을 표시합니다." + "settings.debug.description": "디버그 모드를 활성화하여 API 대화 기록과 UI 메시지를 임시 파일에 포맷된 JSON으로 보기 위한 추가 버튼을 표시합니다.", + "settings.debugProxy.enabled.description": "**Debug Proxy 활성화** — 모든 아웃바운드 네트워크 요청을 MITM 디버깅을 위해 프록시를 통해 라우팅합니다. 디버그 모드(F5)로 실행 중일 때만 활성화됩니다.", + "settings.debugProxy.serverUrl.description": "프록시 URL(예: `http://127.0.0.1:8888`). **Debug Proxy** 가 활성화된 경우에만 사용됩니다.", + "settings.debugProxy.tlsInsecure.description": "프록시의 self-signed 인증서를 허용합니다. **MITM 검사에 필요합니다.** ⚠️ 안전하지 않으므로 로컬 디버깅에만 사용하세요." } diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 0d2d58b62a..fef3ca7219 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Maximale tijd in seconden om te wachten op API-reacties (0 = geen time-out, 1-3600s, standaard: 600s). Hogere waarden worden aanbevolen voor lokale providers zoals LM Studio en Ollama die mogelijk meer verwerkingstijd nodig hebben.", "settings.newTaskRequireTodos.description": "Todos-parameter vereisen bij het maken van nieuwe taken met de new_task tool", "settings.codeIndex.embeddingBatchSize.description": "De batchgrootte voor embedding-operaties tijdens code-indexering. Pas dit aan op basis van de limieten van je API-provider. Standaard is 60.", - "settings.debug.description": "Schakel debug-modus in om extra knoppen te tonen voor het bekijken van API-conversatiegeschiedenis en UI-berichten als opgemaakte JSON in tijdelijke bestanden." + "settings.debug.description": "Schakel debug-modus in om extra knoppen te tonen voor het bekijken van API-conversatiegeschiedenis en UI-berichten als opgemaakte JSON in tijdelijke bestanden.", + "settings.debugProxy.enabled.description": "**Debug Proxy inschakelen** — Leid alle uitgaande netwerkverzoeken via een proxy voor MITM-debugging. Alleen actief wanneer je in debugmodus (F5) draait.", + "settings.debugProxy.serverUrl.description": "Proxy-URL (bijv. `http://127.0.0.1:8888`). Wordt alleen gebruikt wanneer **Debug Proxy** is ingeschakeld.", + "settings.debugProxy.tlsInsecure.description": "Accepteer zelfondertekende certificaten van de proxy. **Vereist voor MITM-inspectie.** ⚠️ Onveilig — gebruik dit alleen voor lokale debugging." } diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index a3314e3886..8c1f66450d 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Maksymalny czas w sekundach oczekiwania na odpowiedzi API (0 = brak limitu czasu, 1-3600s, domyślnie: 600s). Wyższe wartości są zalecane dla lokalnych dostawców, takich jak LM Studio i Ollama, którzy mogą potrzebować więcej czasu na przetwarzanie.", "settings.newTaskRequireTodos.description": "Wymagaj parametru todos podczas tworzenia nowych zadań za pomocą narzędzia new_task", "settings.codeIndex.embeddingBatchSize.description": "Rozmiar partii dla operacji osadzania podczas indeksowania kodu. Dostosuj to w oparciu o limity twojego dostawcy API. Domyślnie to 60.", - "settings.debug.description": "Włącz tryb debugowania, aby wyświetlić dodatkowe przyciski do przeglądania historii rozmów API i komunikatów interfejsu użytkownika jako sformatowany JSON w plikach tymczasowych." + "settings.debug.description": "Włącz tryb debugowania, aby wyświetlić dodatkowe przyciski do przeglądania historii rozmów API i komunikatów interfejsu użytkownika jako sformatowany JSON w plikach tymczasowych.", + "settings.debugProxy.enabled.description": "**Włącz Debug Proxy** — Kieruj wszystkie wychodzące żądania sieciowe przez proxy na potrzeby debugowania MITM. Aktywne tylko wtedy, gdy uruchamiasz w trybie debugowania (F5).", + "settings.debugProxy.serverUrl.description": "URL proxy (np. `http://127.0.0.1:8888`). Używany tylko wtedy, gdy **Debug Proxy** jest włączony.", + "settings.debugProxy.tlsInsecure.description": "Akceptuj certyfikaty self-signed z proxy. **Wymagane do inspekcji MITM.** ⚠️ Niezabezpieczone — używaj tylko do lokalnego debugowania." } diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index 648fc0fa47..84cbf42c09 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Tempo máximo em segundos de espera pelas respostas da API (0 = sem tempo limite, 1-3600s, padrão: 600s). Valores mais altos são recomendados para provedores locais como LM Studio e Ollama que podem precisar de mais tempo de processamento.", "settings.newTaskRequireTodos.description": "Exigir parâmetro todos ao criar novas tarefas com a ferramenta new_task", "settings.codeIndex.embeddingBatchSize.description": "O tamanho do lote para operações de embedding durante a indexação de código. Ajuste isso com base nos limites do seu provedor de API. O padrão é 60.", - "settings.debug.description": "Ativa o modo de depuração para mostrar botões adicionais para visualizar o histórico de conversas da API e mensagens da interface como JSON formatado em arquivos temporários." + "settings.debug.description": "Ativa o modo de depuração para mostrar botões adicionais para visualizar o histórico de conversas da API e mensagens da interface como JSON formatado em arquivos temporários.", + "settings.debugProxy.enabled.description": "**Ativar Debug Proxy** — Redireciona todas as solicitações de rede de saída por meio de um proxy para depuração MITM. Só fica ativo quando você está executando em modo de depuração (F5).", + "settings.debugProxy.serverUrl.description": "URL do proxy (por exemplo, `http://127.0.0.1:8888`). Só é usada quando o **Debug Proxy** está ativado.", + "settings.debugProxy.tlsInsecure.description": "Aceitar certificados self-signed do proxy. **Necessário para inspeção MITM.** ⚠️ Inseguro — use apenas para depuração local." } diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index 00d39e1cf3..be8df04032 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Максимальное время в секундах для ожидания ответов API (0 = нет тайм-аута, 1-3600 с, по умолчанию: 600 с). Рекомендуются более высокие значения для локальных провайдеров, таких как LM Studio и Ollama, которым может потребоваться больше времени на обработку.", "settings.newTaskRequireTodos.description": "Требовать параметр todos при создании новых задач с помощью инструмента new_task", "settings.codeIndex.embeddingBatchSize.description": "Размер пакета для операций встраивания во время индексации кода. Настройте это в соответствии с ограничениями вашего API-провайдера. По умолчанию 60.", - "settings.debug.description": "Включить режим отладки, чтобы отображать дополнительные кнопки для просмотра истории разговоров API и сообщений интерфейса в виде форматированного JSON во временных файлах." + "settings.debug.description": "Включить режим отладки, чтобы отображать дополнительные кнопки для просмотра истории разговоров API и сообщений интерфейса в виде форматированного JSON во временных файлах.", + "settings.debugProxy.enabled.description": "**Включить Debug Proxy** — направлять все исходящие сетевые запросы через прокси для MITM-отладки. Активен только когда ты запускаешь расширение в режиме отладки (F5).", + "settings.debugProxy.serverUrl.description": "URL прокси (например, `http://127.0.0.1:8888`). Используется только если **Debug Proxy** включён.", + "settings.debugProxy.tlsInsecure.description": "Принимать self-signed сертификаты от прокси. **Требуется для MITM-инспекции.** ⚠️ Небезопасно — используй только для локальной отладки." } diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index da05051f6d..a815188e8a 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "API yanıtları için beklenecek maksimum süre (saniye cinsinden) (0 = zaman aşımı yok, 1-3600s, varsayılan: 600s). LM Studio ve Ollama gibi daha fazla işlem süresi gerektirebilecek yerel sağlayıcılar için daha yüksek değerler önerilir.", "settings.newTaskRequireTodos.description": "new_task aracıyla yeni görevler oluştururken todos parametresini gerekli kıl", "settings.codeIndex.embeddingBatchSize.description": "Kod indeksleme sırasında gömme işlemleri için toplu iş boyutu. Bunu API sağlayıcınızın sınırlarına göre ayarlayın. Varsayılan 60'tır.", - "settings.debug.description": "API konuşma geçmişini ve kullanıcı arayüzü mesajlarını geçici dosyalarda biçimlendirilmiş JSON olarak görüntülemek için ek düğmeler göstermek üzere hata ayıklama modunu etkinleştir." + "settings.debug.description": "API konuşma geçmişini ve kullanıcı arayüzü mesajlarını geçici dosyalarda biçimlendirilmiş JSON olarak görüntülemek için ek düğmeler göstermek üzere hata ayıklama modunu etkinleştir.", + "settings.debugProxy.enabled.description": "**Debug Proxy'yi etkinleştir** — Tüm giden ağ isteklerini MITM hata ayıklaması için bir proxy üzerinden yönlendir. Yalnızca debug modunda (F5) çalıştırırken aktiftir.", + "settings.debugProxy.serverUrl.description": "Proxy URL'si (ör. `http://127.0.0.1:8888`). Yalnızca **Debug Proxy** etkin olduğunda kullanılır.", + "settings.debugProxy.tlsInsecure.description": "Proxy'den gelen self-signed sertifikaları kabul et. **MITM incelemesi için gerekli.** ⚠️ Güvensiz — yalnızca lokal debugging için kullan." } diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index 984b009e98..6052080dfa 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "Thời gian tối đa tính bằng giây để đợi phản hồi API (0 = không có thời gian chờ, 1-3600 giây, mặc định: 600 giây). Nên sử dụng các giá trị cao hơn cho các nhà cung cấp cục bộ như LM Studio và Ollama có thể cần thêm thời gian xử lý.", "settings.newTaskRequireTodos.description": "Yêu cầu tham số todos khi tạo nhiệm vụ mới với công cụ new_task", "settings.codeIndex.embeddingBatchSize.description": "Kích thước lô cho các hoạt động nhúng trong quá trình lập chỉ mục mã. Điều chỉnh điều này dựa trên giới hạn của nhà cung cấp API của bạn. Mặc định là 60.", - "settings.debug.description": "Bật chế độ gỡ lỗi để hiển thị các nút bổ sung để xem lịch sử hội thoại API và thông điệp giao diện người dùng dưới dạng JSON được định dạng trong các tệp tạm thời." + "settings.debug.description": "Bật chế độ gỡ lỗi để hiển thị các nút bổ sung để xem lịch sử hội thoại API và thông điệp giao diện người dùng dưới dạng JSON được định dạng trong các tệp tạm thời.", + "settings.debugProxy.enabled.description": "**Bật Debug Proxy** — Chuyển hướng tất cả yêu cầu mạng đi ra qua một proxy để debug MITM. Chỉ hoạt động khi bạn chạy ở chế độ gỡ lỗi (F5).", + "settings.debugProxy.serverUrl.description": "Proxy URL (vd: `http://127.0.0.1:8888`). Chỉ được dùng khi **Debug Proxy** được bật.", + "settings.debugProxy.tlsInsecure.description": "Chấp nhận chứng chỉ self-signed từ proxy. **Bắt buộc cho việc kiểm tra MITM.** ⚠️ Không an toàn — chỉ dùng cho debug cục bộ." } diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index e6619a9491..9254d494d9 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "等待 API 响应的最长时间(秒)(0 = 无超时,1-3600秒,默认值:600秒)。对于像 LM Studio 和 Ollama 这样可能需要更多处理时间的本地提供商,建议使用更高的值。", "settings.newTaskRequireTodos.description": "使用 new_task 工具创建新任务时需要 todos 参数", "settings.codeIndex.embeddingBatchSize.description": "代码索引期间嵌入操作的批处理大小。根据 API 提供商的限制调整此设置。默认值为 60。", - "settings.debug.description": "启用调试模式以显示额外按钮,用于在临时文件中以格式化 JSON 查看 API 对话历史和 UI 消息。" + "settings.debug.description": "启用调试模式以显示额外按钮,用于在临时文件中以格式化 JSON 查看 API 对话历史和 UI 消息。", + "settings.debugProxy.enabled.description": "**启用 Debug Proxy** — 通过代理转发所有出站网络请求,用于 MITM 调试。只在调试模式 (F5) 运行时生效。", + "settings.debugProxy.serverUrl.description": "代理 URL(例如 `http://127.0.0.1:8888`)。仅在启用 **Debug Proxy** 时使用。", + "settings.debugProxy.tlsInsecure.description": "接受来自代理的 self-signed 证书。**MITM 检查所必需。** ⚠️ 不安全——只在本地调试时使用。" } diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index 38a9807126..a8030d6914 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -42,5 +42,8 @@ "settings.apiRequestTimeout.description": "等待 API 回應的最長時間(秒)(0 = 無超時,1-3600秒,預設值:600秒)。對於像 LM Studio 和 Ollama 這樣可能需要更多處理時間的本地提供商,建議使用更高的值。", "settings.newTaskRequireTodos.description": "使用 new_task 工具建立新工作時需要 todos 參數", "settings.codeIndex.embeddingBatchSize.description": "程式碼索引期間嵌入操作的批次大小。根據 API 提供商的限制調整此設定。預設值為 60。", - "settings.debug.description": "啟用偵錯模式以顯示額外按鈕,用於在暫存檔案中以格式化 JSON 檢視 API 對話歷史紀錄和使用者介面訊息。" + "settings.debug.description": "啟用偵錯模式以顯示額外按鈕,用於在暫存檔案中以格式化 JSON 檢視 API 對話歷史紀錄和使用者介面訊息。", + "settings.debugProxy.enabled.description": "**啟用 Debug Proxy** — 將所有出站網路要求透過代理進行路由,以進行 MITM 偵錯。只有在除錯模式 (F5) 執行時才會啟用。", + "settings.debugProxy.serverUrl.description": "代理 URL(例如 `http://127.0.0.1:8888`)。只有在啟用 **Debug Proxy** 時才會使用。", + "settings.debugProxy.tlsInsecure.description": "接受來自代理的 self-signed 憑證。**MITM 檢查所必需。** ⚠️ 不安全——只在本機偵錯時使用。" } diff --git a/src/services/command/__tests__/frontmatter-commands.spec.ts b/src/services/command/__tests__/frontmatter-commands.spec.ts index 40acc8ae84..3f93b55f94 100644 --- a/src/services/command/__tests__/frontmatter-commands.spec.ts +++ b/src/services/command/__tests__/frontmatter-commands.spec.ts @@ -49,6 +49,7 @@ npm run build filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"), description: "Sets up the development environment", argumentHint: undefined, + mode: undefined, }) }) @@ -73,6 +74,7 @@ npm run build filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"), description: undefined, argumentHint: undefined, + mode: undefined, }) }) @@ -116,6 +118,7 @@ Command content here.` filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"), description: undefined, argumentHint: undefined, + mode: undefined, }) }) @@ -151,6 +154,7 @@ Global setup instructions.` filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"), description: "Project-specific setup", argumentHint: undefined, + mode: undefined, }) }) @@ -178,6 +182,7 @@ Global setup instructions.` filePath: expect.stringContaining(path.join(".roo", "commands", "setup.md")), description: "Global setup command", argumentHint: undefined, + mode: undefined, }) }) }) @@ -205,6 +210,7 @@ Create a new release.` filePath: path.join("/test/cwd", ".roo", "commands", "release.md"), description: "Create a new release of the Roo Code extension", argumentHint: "patch | minor | major", + mode: undefined, }) }) @@ -231,6 +237,7 @@ Deploy the application.` filePath: path.join("/test/cwd", ".roo", "commands", "deploy.md"), description: "Deploy application to environment", argumentHint: "staging | production", + mode: undefined, }) }) @@ -287,6 +294,77 @@ Test content.` expect(result?.argumentHint).toBeUndefined() }) + + it("should load command with mode from frontmatter", async () => { + const commandContent = `--- +description: Debug the application +mode: debug +--- + +# Debug Command + +Start debugging.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const result = await getCommand("/test/cwd", "debug-app") + + expect(result).toEqual({ + name: "debug-app", + content: "# Debug Command\n\nStart debugging.", + source: "project", + filePath: path.join("/test/cwd", ".roo", "commands", "debug-app.md"), + description: "Debug the application", + argumentHint: undefined, + mode: "debug", + }) + }) + + it("should handle empty mode in frontmatter", async () => { + const commandContent = `--- +description: Test command +mode: "" +--- + +# Test Command + +Test content.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const result = await getCommand("/test/cwd", "test") + + expect(result?.mode).toBeUndefined() + }) + + it("should handle command with description, argument-hint, and mode", async () => { + const commandContent = `--- +description: Deploy to environment +argument-hint: staging | production +mode: code +--- + +# Deploy Command + +Deploy the application.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const result = await getCommand("/test/cwd", "deploy") + + expect(result).toEqual({ + name: "deploy", + content: "# Deploy Command\n\nDeploy the application.", + source: "project", + filePath: path.join("/test/cwd", ".roo", "commands", "deploy.md"), + description: "Deploy to environment", + argumentHint: "staging | production", + mode: "code", + }) + }) }) describe("getCommands with frontmatter", () => { diff --git a/src/services/command/commands.ts b/src/services/command/commands.ts index 6a5b4c6db1..4e69558dc1 100644 --- a/src/services/command/commands.ts +++ b/src/services/command/commands.ts @@ -17,6 +17,7 @@ export interface Command { filePath: string description?: string argumentHint?: string + mode?: string } /** @@ -215,6 +216,7 @@ async function tryLoadCommand( let parsed let description: string | undefined let argumentHint: string | undefined + let mode: string | undefined let commandContent: string try { @@ -228,11 +230,13 @@ async function tryLoadCommand( typeof parsed.data["argument-hint"] === "string" && parsed.data["argument-hint"].trim() ? parsed.data["argument-hint"].trim() : undefined + mode = typeof parsed.data.mode === "string" && parsed.data.mode.trim() ? parsed.data.mode.trim() : undefined commandContent = parsed.content.trim() } catch { // If frontmatter parsing fails, treat the entire content as command content description = undefined argumentHint = undefined + mode = undefined commandContent = content.trim() } @@ -243,6 +247,7 @@ async function tryLoadCommand( filePath: resolvedPath, description, argumentHint, + mode, } } catch { // Directory doesn't exist or can't be read @@ -296,6 +301,7 @@ async function scanCommandDirectory( let parsed let description: string | undefined let argumentHint: string | undefined + let mode: string | undefined let commandContent: string try { @@ -309,11 +315,16 @@ async function scanCommandDirectory( typeof parsed.data["argument-hint"] === "string" && parsed.data["argument-hint"].trim() ? parsed.data["argument-hint"].trim() : undefined + mode = + typeof parsed.data.mode === "string" && parsed.data.mode.trim() + ? parsed.data.mode.trim() + : undefined commandContent = parsed.content.trim() } catch { // If frontmatter parsing fails, treat the entire content as command content description = undefined argumentHint = undefined + mode = undefined commandContent = content.trim() } @@ -326,6 +337,7 @@ async function scanCommandDirectory( filePath: resolvedPath, description, argumentHint, + mode, }) } } catch (error) { diff --git a/src/services/roo-config/__tests__/index.spec.ts b/src/services/roo-config/__tests__/index.spec.ts index 946bb27c7f..c060cdcb5a 100644 --- a/src/services/roo-config/__tests__/index.spec.ts +++ b/src/services/roo-config/__tests__/index.spec.ts @@ -1,10 +1,11 @@ import * as path from "path" // Use vi.hoisted to ensure mocks are available during hoisting -const { mockStat, mockReadFile, mockHomedir } = vi.hoisted(() => ({ +const { mockStat, mockReadFile, mockHomedir, mockExecuteRipgrep } = vi.hoisted(() => ({ mockStat: vi.fn(), mockReadFile: vi.fn(), mockHomedir: vi.fn(), + mockExecuteRipgrep: vi.fn(), })) // Mock fs/promises module @@ -20,6 +21,11 @@ vi.mock("os", () => ({ homedir: mockHomedir, })) +// Mock executeRipgrep from search service +vi.mock("../../search/file-search", () => ({ + executeRipgrep: mockExecuteRipgrep, +})) + import { getGlobalRooDirectory, getProjectRooDirectoryForCwd, @@ -27,6 +33,9 @@ import { fileExists, readFileIfExists, getRooDirectoriesForCwd, + getAllRooDirectoriesForCwd, + getAgentsDirectoriesForCwd, + discoverSubfolderRooDirectories, loadConfiguration, } from "../index" @@ -297,4 +306,188 @@ describe("RooConfigService", () => { expect(mockReadFile).toHaveBeenCalledWith(path.join("/project/path", ".roo", "rules/rules.md"), "utf-8") }) }) + + describe("discoverSubfolderRooDirectories", () => { + it("should return empty array when no subfolder .roo directories found", async () => { + mockExecuteRipgrep.mockResolvedValue([]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([]) + }) + + it("should discover .roo directories from subfolders", async () => { + // Find any file inside .roo directories + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "package-a/.roo/rules/rule.md", type: "file" }, + { path: "package-b/.roo/rules-code/rule.md", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([ + path.join("/project/path", "package-a", ".roo"), + path.join("/project/path", "package-b", ".roo"), + ]) + }) + + it("should sort discovered directories alphabetically", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "zebra/.roo/rules/rule.md", type: "file" }, + { path: "apple/.roo/rules/rule.md", type: "file" }, + { path: "mango/.roo/rules/rule.md", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([ + path.join("/project/path", "apple", ".roo"), + path.join("/project/path", "mango", ".roo"), + path.join("/project/path", "zebra", ".roo"), + ]) + }) + + it("should exclude root .roo directory", async () => { + // This would match the root .roo, which should be excluded + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: ".roo/rules/rule.md", type: "file" }, // This is root - should be excluded + { path: "subfolder/.roo/rules/rule.md", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + // Should only include subfolder, not root + expect(result).toEqual([path.join("/project/path", "subfolder", ".roo")]) + }) + + it("should handle nested subdirectories", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "packages/core/.roo/rules/rule.md", type: "file" }, + { path: "packages/utils/.roo/rules-code/rule.md", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([ + path.join("/project/path", "packages/core", ".roo"), + path.join("/project/path", "packages/utils", ".roo"), + ]) + }) + + it("should return empty array on ripgrep error", async () => { + mockExecuteRipgrep.mockRejectedValue(new Error("ripgrep failed")) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([]) + }) + + it("should deduplicate .roo directories from multiple files", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "package-a/.roo/rules/rule1.md", type: "file" }, + { path: "package-a/.roo/rules/rule2.md", type: "file" }, + { path: "package-a/.roo/rules-code/rule3.md", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + // Should only include package-a/.roo once + expect(result).toEqual([path.join("/project/path", "package-a", ".roo")]) + }) + + it("should discover .roo directories with any content", async () => { + // Should find .roo directories regardless of what's inside them + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "package-a/.roo/rules/rule.md", type: "file" }, + { path: "package-b/.roo/rules-code/code-rule.md", type: "file" }, + { path: "package-c/.roo/rules-architect/arch-rule.md", type: "file" }, + { path: "package-d/.roo/config/settings.json", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([ + path.join("/project/path", "package-a", ".roo"), + path.join("/project/path", "package-b", ".roo"), + path.join("/project/path", "package-c", ".roo"), + path.join("/project/path", "package-d", ".roo"), + ]) + }) + }) + + describe("getAllRooDirectoriesForCwd", () => { + it("should return global, project, and subfolder directories", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([{ path: "subfolder/.roo/rules/rule.md", type: "file" }]) + + const result = await getAllRooDirectoriesForCwd("/project/path") + + expect(result).toEqual([ + path.join("/mock/home", ".roo"), // global + path.join("/project/path", ".roo"), // project + path.join("/project/path", "subfolder", ".roo"), // subfolder + ]) + }) + + it("should return only global and project when no subfolders", async () => { + mockExecuteRipgrep.mockResolvedValue([]) + + const result = await getAllRooDirectoriesForCwd("/project/path") + + expect(result).toEqual([path.join("/mock/home", ".roo"), path.join("/project/path", ".roo")]) + }) + + it("should maintain order: global, project, subfolders (alphabetically)", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "zebra/.roo/rules/rule.md", type: "file" }, + { path: "apple/.roo/rules/rule.md", type: "file" }, + ]) + + const result = await getAllRooDirectoriesForCwd("/project/path") + + expect(result).toEqual([ + path.join("/mock/home", ".roo"), // global first + path.join("/project/path", ".roo"), // project second + path.join("/project/path", "apple", ".roo"), // subfolders alphabetically + path.join("/project/path", "zebra", ".roo"), + ]) + }) + }) + + describe("getAgentsDirectoriesForCwd", () => { + it("should return root directory and parent directories of subfolder .roo dirs", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([{ path: "package-a/.roo/rules/rule.md", type: "file" }]) + + const result = await getAgentsDirectoriesForCwd("/project/path") + + expect(result).toEqual([ + "/project/path", // root + path.join("/project/path", "package-a"), // parent of .roo + ]) + }) + + it("should always include root even when no subfolders", async () => { + mockExecuteRipgrep.mockResolvedValue([]) + + const result = await getAgentsDirectoriesForCwd("/project/path") + + expect(result).toEqual(["/project/path"]) + }) + + it("should include multiple subfolder parent directories", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "package-a/.roo/rules/rule.md", type: "file" }, + { path: "package-b/.roo/rules-code/rule.md", type: "file" }, + { path: "packages/core/.roo/rules/rule.md", type: "file" }, + ]) + + const result = await getAgentsDirectoriesForCwd("/project/path") + + expect(result).toEqual([ + "/project/path", + path.join("/project/path", "package-a"), + path.join("/project/path", "package-b"), + path.join("/project/path", "packages/core"), + ]) + }) + }) }) diff --git a/src/services/roo-config/index.ts b/src/services/roo-config/index.ts index b46c39e354..166617834d 100644 --- a/src/services/roo-config/index.ts +++ b/src/services/roo-config/index.ts @@ -111,6 +111,89 @@ export async function readFileIfExists(filePath: string): Promise } } +/** + * Discovers all .roo directories in subdirectories of the workspace + * + * @param cwd - Current working directory (workspace root) + * @returns Array of absolute paths to .roo directories found in subdirectories, + * sorted alphabetically. Does not include the root .roo directory. + * + * @example + * ```typescript + * const subfolderRoos = await discoverSubfolderRooDirectories('/Users/john/monorepo') + * // Returns: + * // [ + * // '/Users/john/monorepo/package-a/.roo', + * // '/Users/john/monorepo/package-b/.roo', + * // '/Users/john/monorepo/packages/shared/.roo' + * // ] + * ``` + * + * @example Directory structure: + * ``` + * /Users/john/monorepo/ + * ├── .roo/ # Root .roo (NOT included - use getProjectRooDirectoryForCwd) + * ├── package-a/ + * │ └── .roo/ # Included + * │ └── rules/ + * ├── package-b/ + * │ └── .roo/ # Included + * │ └── rules-code/ + * └── packages/ + * └── shared/ + * └── .roo/ # Included (nested) + * └── rules/ + * ``` + */ +export async function discoverSubfolderRooDirectories(cwd: string): Promise { + try { + // Dynamic import to avoid vscode dependency at module load time + // This is necessary because file-search.ts imports vscode, which is not + // available in the webview context + const { executeRipgrep } = await import("../search/file-search") + + // Use ripgrep to find any file inside any .roo directory + // This efficiently discovers all .roo folders regardless of their content + const args = [ + "--files", + "--hidden", + "--follow", + "-g", + "**/.roo/**", + "-g", + "!node_modules/**", + "-g", + "!.git/**", + cwd, + ] + + const results = await executeRipgrep({ args, workspacePath: cwd }) + + // Extract unique .roo directory paths + const rooDirs = new Set() + const rootRooDir = path.join(cwd, ".roo") + + for (const result of results) { + // Match paths like "subfolder/.roo/anything" or "subfolder/nested/.roo/anything" + // Handle both forward slashes (Unix) and backslashes (Windows) + const match = result.path.match(/^(.+?)[/\\]\.roo[/\\]/) + if (match) { + const rooDir = path.join(cwd, match[1], ".roo") + // Exclude the root .roo directory (already handled by getProjectRooDirectoryForCwd) + if (rooDir !== rootRooDir) { + rooDirs.add(rooDir) + } + } + } + + // Return sorted alphabetically + return Array.from(rooDirs).sort() + } catch (error) { + // If discovery fails (e.g., ripgrep not available), return empty array + return [] + } +} + /** * Gets the ordered list of .roo directories to check (global first, then project-local) * @@ -156,6 +239,71 @@ export function getRooDirectoriesForCwd(cwd: string): string[] { return directories } +/** + * Gets the ordered list of all .roo directories including subdirectories + * + * @param cwd - Current working directory (project path) + * @returns Array of directory paths in order: [global, project-local, ...subfolders (alphabetically)] + * + * @example + * ```typescript + * // For a monorepo at /Users/john/monorepo with .roo in subfolders + * const directories = await getAllRooDirectoriesForCwd('/Users/john/monorepo') + * // Returns: + * // [ + * // '/Users/john/.roo', // Global directory + * // '/Users/john/monorepo/.roo', // Project-local directory + * // '/Users/john/monorepo/package-a/.roo', // Subfolder (alphabetical) + * // '/Users/john/monorepo/package-b/.roo' // Subfolder (alphabetical) + * // ] + * ``` + */ +export async function getAllRooDirectoriesForCwd(cwd: string): Promise { + const directories: string[] = [] + + // Add global directory first + directories.push(getGlobalRooDirectory()) + + // Add project-local directory second + directories.push(getProjectRooDirectoryForCwd(cwd)) + + // Discover and add subfolder .roo directories + const subfolderDirs = await discoverSubfolderRooDirectories(cwd) + directories.push(...subfolderDirs) + + return directories +} + +/** + * Gets parent directories containing .roo folders, in order from root to subfolders + * + * @param cwd - Current working directory (project path) + * @returns Array of parent directory paths (not .roo paths) containing AGENTS.md or .roo + * + * @example + * ```typescript + * const dirs = await getAgentsDirectoriesForCwd('/Users/john/monorepo') + * // Returns: ['/Users/john/monorepo', '/Users/john/monorepo/package-a', ...] + * ``` + */ +export async function getAgentsDirectoriesForCwd(cwd: string): Promise { + const directories: string[] = [] + + // Always include the root directory + directories.push(cwd) + + // Get all subfolder .roo directories + const subfolderRooDirs = await discoverSubfolderRooDirectories(cwd) + + // Extract parent directories (remove .roo from path) + for (const rooDir of subfolderRooDirs) { + const parentDir = path.dirname(rooDir) + directories.push(parentDir) + } + + return directories +} + /** * Loads configuration from multiple .roo directories with project overriding global * diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts new file mode 100644 index 0000000000..59b50cf171 --- /dev/null +++ b/src/services/skills/SkillsManager.ts @@ -0,0 +1,360 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as vscode from "vscode" +import matter from "gray-matter" + +import type { ClineProvider } from "../../core/webview/ClineProvider" +import { getGlobalRooDirectory } from "../roo-config" +import { directoryExists, fileExists } from "../roo-config" +import { SkillMetadata, SkillContent } from "../../shared/skills" +import { modes, getAllModes } from "../../shared/modes" + +// Re-export for convenience +export type { SkillMetadata, SkillContent } + +export class SkillsManager { + private skills: Map = new Map() + private providerRef: WeakRef + private disposables: vscode.Disposable[] = [] + private isDisposed = false + + constructor(provider: ClineProvider) { + this.providerRef = new WeakRef(provider) + } + + async initialize(): Promise { + await this.discoverSkills() + await this.setupFileWatchers() + } + + /** + * Discover all skills from global and project directories. + * Supports both generic skills (skills/) and mode-specific skills (skills-{mode}/). + * Also supports symlinks: + * - .roo/skills can be a symlink to a directory containing skill subdirectories + * - .roo/skills/[dirname] can be a symlink to a skill directory + */ + async discoverSkills(): Promise { + this.skills.clear() + const skillsDirs = await this.getSkillsDirectories() + + for (const { dir, source, mode } of skillsDirs) { + await this.scanSkillsDirectory(dir, source, mode) + } + } + + /** + * Scan a skills directory for skill subdirectories. + * Handles two symlink cases: + * 1. The skills directory itself is a symlink (resolved by directoryExists using realpath) + * 2. Individual skill subdirectories are symlinks + */ + private async scanSkillsDirectory(dirPath: string, source: "global" | "project", mode?: string): Promise { + if (!(await directoryExists(dirPath))) { + return + } + + try { + // Get the real path (resolves if dirPath is a symlink) + const realDirPath = await fs.realpath(dirPath) + + // Read directory entries + const entries = await fs.readdir(realDirPath) + + for (const entryName of entries) { + const entryPath = path.join(realDirPath, entryName) + + // Check if this entry is a directory (follows symlinks automatically) + const stats = await fs.stat(entryPath).catch(() => null) + if (!stats?.isDirectory()) continue + + // Load skill metadata - the skill name comes from the entry name (symlink name if symlinked) + await this.loadSkillMetadata(entryPath, source, mode, entryName) + } + } catch { + // Directory doesn't exist or can't be read - this is fine + } + } + + /** + * Load skill metadata from a skill directory. + * @param skillDir - The resolved path to the skill directory (target of symlink if symlinked) + * @param source - Whether this is a global or project skill + * @param mode - The mode this skill is specific to (undefined for generic skills) + * @param skillName - The skill name (from symlink name if symlinked, otherwise from directory name) + */ + private async loadSkillMetadata( + skillDir: string, + source: "global" | "project", + mode?: string, + skillName?: string, + ): Promise { + const skillMdPath = path.join(skillDir, "SKILL.md") + if (!(await fileExists(skillMdPath))) return + + try { + const fileContent = await fs.readFile(skillMdPath, "utf-8") + + // Use gray-matter to parse frontmatter + const { data: frontmatter, content: body } = matter(fileContent) + + // Validate required fields (only name and description for now) + if (!frontmatter.name || typeof frontmatter.name !== "string") { + console.error(`Skill at ${skillDir} is missing required 'name' field`) + return + } + if (!frontmatter.description || typeof frontmatter.description !== "string") { + console.error(`Skill at ${skillDir} is missing required 'description' field`) + return + } + + // Validate that frontmatter name matches the skill name (directory name or symlink name) + // Per the Agent Skills spec: "name field must match the parent directory name" + const effectiveSkillName = skillName || path.basename(skillDir) + if (frontmatter.name !== effectiveSkillName) { + console.error(`Skill name "${frontmatter.name}" doesn't match directory "${effectiveSkillName}"`) + return + } + + // Strict spec validation (https://agentskills.io/specification) + // Name constraints: + // - 1-64 chars + // - lowercase letters/numbers/hyphens only + // - must not start/end with hyphen + // - must not contain consecutive hyphens + if (effectiveSkillName.length < 1 || effectiveSkillName.length > 64) { + console.error( + `Skill name "${effectiveSkillName}" is invalid: name must be 1-64 characters (got ${effectiveSkillName.length})`, + ) + return + } + const nameFormat = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + if (!nameFormat.test(effectiveSkillName)) { + console.error( + `Skill name "${effectiveSkillName}" is invalid: must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)`, + ) + return + } + + // Description constraints: + // - 1-1024 chars + // - non-empty (after trimming) + const description = frontmatter.description.trim() + if (description.length < 1 || description.length > 1024) { + console.error( + `Skill "${effectiveSkillName}" has an invalid description length: must be 1-1024 characters (got ${description.length})`, + ) + return + } + + // Create unique key combining name, source, and mode for override resolution + const skillKey = this.getSkillKey(effectiveSkillName, source, mode) + + this.skills.set(skillKey, { + name: effectiveSkillName, + description, + path: skillMdPath, + source, + mode, // undefined for generic skills, string for mode-specific + }) + } catch (error) { + console.error(`Failed to load skill at ${skillDir}:`, error) + } + } + + /** + * Get skills available for the current mode. + * Resolves overrides: project > global, mode-specific > generic. + * + * @param currentMode - The current mode slug (e.g., 'code', 'architect') + */ + getSkillsForMode(currentMode: string): SkillMetadata[] { + const resolvedSkills = new Map() + + for (const skill of this.skills.values()) { + // Skip mode-specific skills that don't match current mode + if (skill.mode && skill.mode !== currentMode) continue + + const existingSkill = resolvedSkills.get(skill.name) + + if (!existingSkill) { + resolvedSkills.set(skill.name, skill) + continue + } + + // Apply override rules + const shouldOverride = this.shouldOverrideSkill(existingSkill, skill) + if (shouldOverride) { + resolvedSkills.set(skill.name, skill) + } + } + + return Array.from(resolvedSkills.values()) + } + + /** + * Determine if newSkill should override existingSkill based on priority rules. + * Priority: project > global, mode-specific > generic + */ + private shouldOverrideSkill(existing: SkillMetadata, newSkill: SkillMetadata): boolean { + // Project always overrides global + if (newSkill.source === "project" && existing.source === "global") return true + if (newSkill.source === "global" && existing.source === "project") return false + + // Same source: mode-specific overrides generic + if (newSkill.mode && !existing.mode) return true + if (!newSkill.mode && existing.mode) return false + + // Same source and same mode-specificity: keep existing (first wins) + return false + } + + /** + * Get all skills (for UI display, debugging, etc.) + */ + getAllSkills(): SkillMetadata[] { + return Array.from(this.skills.values()) + } + + async getSkillContent(name: string, currentMode?: string): Promise { + // If mode is provided, try to find the best matching skill + let skill: SkillMetadata | undefined + + if (currentMode) { + const modeSkills = this.getSkillsForMode(currentMode) + skill = modeSkills.find((s) => s.name === name) + } else { + // Fall back to any skill with this name + skill = Array.from(this.skills.values()).find((s) => s.name === name) + } + + if (!skill) return null + + const fileContent = await fs.readFile(skill.path, "utf-8") + const { content: body } = matter(fileContent) + + return { + ...skill, + instructions: body.trim(), + } + } + + /** + * Get all skills directories to scan, including mode-specific directories. + */ + private async getSkillsDirectories(): Promise< + Array<{ + dir: string + source: "global" | "project" + mode?: string + }> + > { + const dirs: Array<{ dir: string; source: "global" | "project"; mode?: string }> = [] + const globalRooDir = getGlobalRooDirectory() + const provider = this.providerRef.deref() + const projectRooDir = provider?.cwd ? path.join(provider.cwd, ".roo") : null + + // Get list of modes to check for mode-specific skills + const modesList = await this.getAvailableModes() + + // Global directories + dirs.push({ dir: path.join(globalRooDir, "skills"), source: "global" }) + for (const mode of modesList) { + dirs.push({ dir: path.join(globalRooDir, `skills-${mode}`), source: "global", mode }) + } + + // Project directories + if (projectRooDir) { + dirs.push({ dir: path.join(projectRooDir, "skills"), source: "project" }) + for (const mode of modesList) { + dirs.push({ dir: path.join(projectRooDir, `skills-${mode}`), source: "project", mode }) + } + } + + return dirs + } + + /** + * Get list of available modes (built-in + custom) + */ + private async getAvailableModes(): Promise { + const provider = this.providerRef.deref() + const builtInModeSlugs = modes.map((m) => m.slug) + + if (!provider) { + return builtInModeSlugs + } + + try { + const customModes = await provider.customModesManager.getCustomModes() + const allModes = getAllModes(customModes) + return allModes.map((m) => m.slug) + } catch { + return builtInModeSlugs + } + } + + private getSkillKey(name: string, source: string, mode?: string): string { + return `${source}:${mode || "generic"}:${name}` + } + + private async setupFileWatchers(): Promise { + // Skip if test environment is detected or VSCode APIs are not available + if (process.env.NODE_ENV === "test" || !vscode.workspace.createFileSystemWatcher) { + return + } + + const provider = this.providerRef.deref() + if (!provider?.cwd) return + + // Watch for changes in skills directories + const globalSkillsDir = path.join(getGlobalRooDirectory(), "skills") + const projectSkillsDir = path.join(provider.cwd, ".roo", "skills") + + // Watch global skills directory + this.watchDirectory(globalSkillsDir) + + // Watch project skills directory + this.watchDirectory(projectSkillsDir) + + // Watch mode-specific directories for all available modes + const modesList = await this.getAvailableModes() + for (const mode of modesList) { + this.watchDirectory(path.join(getGlobalRooDirectory(), `skills-${mode}`)) + this.watchDirectory(path.join(provider.cwd, ".roo", `skills-${mode}`)) + } + } + + private watchDirectory(dirPath: string): void { + if (process.env.NODE_ENV === "test" || !vscode.workspace.createFileSystemWatcher) { + return + } + + const pattern = new vscode.RelativePattern(dirPath, "**/SKILL.md") + const watcher = vscode.workspace.createFileSystemWatcher(pattern) + + watcher.onDidChange(async (uri) => { + if (this.isDisposed) return + await this.discoverSkills() + }) + + watcher.onDidCreate(async (uri) => { + if (this.isDisposed) return + await this.discoverSkills() + }) + + watcher.onDidDelete(async (uri) => { + if (this.isDisposed) return + await this.discoverSkills() + }) + + this.disposables.push(watcher) + } + + async dispose(): Promise { + this.isDisposed = true + this.disposables.forEach((d) => d.dispose()) + this.disposables = [] + this.skills.clear() + } +} diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts new file mode 100644 index 0000000000..4b6549108b --- /dev/null +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -0,0 +1,830 @@ +import * as path from "path" + +// Use vi.hoisted to ensure mocks are available during hoisting +const { mockStat, mockReadFile, mockReaddir, mockHomedir, mockDirectoryExists, mockFileExists, mockRealpath } = + vi.hoisted(() => ({ + mockStat: vi.fn(), + mockReadFile: vi.fn(), + mockReaddir: vi.fn(), + mockHomedir: vi.fn(), + mockDirectoryExists: vi.fn(), + mockFileExists: vi.fn(), + mockRealpath: vi.fn(), + })) + +// Platform-agnostic test paths +// Use forward slashes for consistency, then normalize with path.normalize +const HOME_DIR = process.platform === "win32" ? "C:\\Users\\testuser" : "/home/user" +const PROJECT_DIR = process.platform === "win32" ? "C:\\test\\project" : "/test/project" +const SHARED_DIR = process.platform === "win32" ? "C:\\shared\\skills" : "/shared/skills" + +// Helper to create platform-appropriate paths +const p = (...segments: string[]) => path.join(...segments) + +// Mock fs/promises module +vi.mock("fs/promises", () => ({ + default: { + stat: mockStat, + readFile: mockReadFile, + readdir: mockReaddir, + realpath: mockRealpath, + }, + stat: mockStat, + readFile: mockReadFile, + readdir: mockReaddir, + realpath: mockRealpath, +})) + +// Mock os module +vi.mock("os", () => ({ + homedir: mockHomedir, +})) + +// Mock vscode +vi.mock("vscode", () => ({ + workspace: { + createFileSystemWatcher: vi.fn(() => ({ + onDidChange: vi.fn(), + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + })), + }, + RelativePattern: vi.fn(), +})) + +// Global roo directory - computed once +const GLOBAL_ROO_DIR = p(HOME_DIR, ".roo") + +// Mock roo-config +vi.mock("../../roo-config", () => ({ + getGlobalRooDirectory: () => GLOBAL_ROO_DIR, + directoryExists: mockDirectoryExists, + fileExists: mockFileExists, +})) + +import { SkillsManager } from "../SkillsManager" +import { ClineProvider } from "../../../core/webview/ClineProvider" + +describe("SkillsManager", () => { + let skillsManager: SkillsManager + let mockProvider: Partial + + // Pre-computed paths for tests + const globalSkillsDir = p(GLOBAL_ROO_DIR, "skills") + const globalSkillsCodeDir = p(GLOBAL_ROO_DIR, "skills-code") + const globalSkillsArchitectDir = p(GLOBAL_ROO_DIR, "skills-architect") + const projectRooDir = p(PROJECT_DIR, ".roo") + const projectSkillsDir = p(projectRooDir, "skills") + + beforeEach(() => { + vi.clearAllMocks() + mockHomedir.mockReturnValue(HOME_DIR) + + // Create mock provider + mockProvider = { + cwd: PROJECT_DIR, + customModesManager: { + getCustomModes: vi.fn().mockResolvedValue([]), + } as any, + } + + skillsManager = new SkillsManager(mockProvider as ClineProvider) + }) + + afterEach(async () => { + await skillsManager.dispose() + }) + + describe("discoverSkills", () => { + it("should discover skills from global directory", async () => { + const pdfSkillDir = p(globalSkillsDir, "pdf-processing") + const pdfSkillMd = p(pdfSkillDir, "SKILL.md") + + // Setup mocks + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["pdf-processing"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === pdfSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === pdfSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === pdfSkillMd) { + return `--- +name: pdf-processing +description: Extract text and tables from PDF files +--- + +# PDF Processing + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("pdf-processing") + expect(skills[0].description).toBe("Extract text and tables from PDF files") + expect(skills[0].source).toBe("global") + }) + + it("should discover skills from project directory", async () => { + const codeReviewDir = p(projectSkillsDir, "code-review") + const codeReviewMd = p(codeReviewDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === projectSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === projectSkillsDir) { + return ["code-review"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === codeReviewDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === codeReviewMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === codeReviewMd) { + return `--- +name: code-review +description: Review code for best practices +--- + +# Code Review + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("code-review") + expect(skills[0].source).toBe("project") + }) + + it("should discover mode-specific skills", async () => { + const refactoringDir = p(globalSkillsCodeDir, "refactoring") + const refactoringMd = p(refactoringDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsCodeDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsCodeDir) { + return ["refactoring"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === refactoringDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === refactoringMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === refactoringMd) { + return `--- +name: refactoring +description: Refactor code for better maintainability +--- + +# Refactoring + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("refactoring") + expect(skills[0].mode).toBe("code") + }) + + it("should skip skills with missing required fields", async () => { + const invalidSkillDir = p(globalSkillsDir, "invalid-skill") + const invalidSkillMd = p(invalidSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["invalid-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === invalidSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === invalidSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === invalidSkillMd) { + return `--- +name: invalid-skill +--- + +# Missing description field` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + + it("should skip skills where name doesn't match directory", async () => { + const mySkillDir = p(globalSkillsDir, "my-skill") + const mySkillMd = p(mySkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["my-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === mySkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === mySkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === mySkillMd) { + return `--- +name: different-name +description: Name doesn't match directory +--- + +# Mismatched name` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + + it("should skip skills with invalid name formats (spec compliance)", async () => { + const invalidNames = [ + "PDF-processing", // uppercase + "-pdf", // leading hyphen + "pdf-", // trailing hyphen + "pdf--processing", // consecutive hyphens + ] + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? invalidNames : [])) + + mockStat.mockImplementation(async (pathArg: string) => { + if (invalidNames.some((name) => pathArg === p(globalSkillsDir, name))) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return invalidNames.some((name) => file === p(globalSkillsDir, name, "SKILL.md")) + }) + + mockReadFile.mockImplementation(async (file: string) => { + const match = invalidNames.find((name) => file === p(globalSkillsDir, name, "SKILL.md")) + if (!match) throw new Error("File not found") + return `--- +name: ${match} +description: Invalid name format +--- + +# Invalid Skill` + }) + + await skillsManager.discoverSkills() + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + + it("should skip skills with name longer than 64 characters (spec compliance)", async () => { + const longName = "a".repeat(65) + const longDir = p(globalSkillsDir, longName) + const longMd = p(longDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? [longName] : [])) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === longDir) return { isDirectory: () => true } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => file === longMd) + mockReadFile.mockResolvedValue(`--- +name: ${longName} +description: Too long name +--- + +# Long Name Skill`) + + await skillsManager.discoverSkills() + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + + it("should skip skills with empty/whitespace-only description (spec compliance)", async () => { + const skillDir = p(globalSkillsDir, "valid-name") + const skillMd = p(skillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["valid-name"] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === skillDir) return { isDirectory: () => true } + throw new Error("Not found") + }) + mockFileExists.mockImplementation(async (file: string) => file === skillMd) + mockReadFile.mockResolvedValue(`--- +name: valid-name +description: " " +--- + +# Empty Description`) + + await skillsManager.discoverSkills() + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + + it("should skip skills with too-long descriptions (spec compliance)", async () => { + const skillDir = p(globalSkillsDir, "valid-name") + const skillMd = p(skillDir, "SKILL.md") + const longDescription = "d".repeat(1025) + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["valid-name"] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === skillDir) return { isDirectory: () => true } + throw new Error("Not found") + }) + mockFileExists.mockImplementation(async (file: string) => file === skillMd) + mockReadFile.mockResolvedValue(`--- +name: valid-name +description: ${longDescription} +--- + +# Too Long Description`) + + await skillsManager.discoverSkills() + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + + it("should handle symlinked skills directory", async () => { + const sharedSkillDir = p(SHARED_DIR, "shared-skill") + const sharedSkillMd = p(sharedSkillDir, "SKILL.md") + + // Simulate .roo/skills being a symlink to /shared/skills + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + // realpath resolves the symlink to the actual directory + mockRealpath.mockImplementation(async (pathArg: string) => { + if (pathArg === globalSkillsDir) { + return SHARED_DIR + } + return pathArg + }) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === SHARED_DIR) { + return ["shared-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sharedSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === sharedSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === sharedSkillMd) { + return `--- +name: shared-skill +description: A skill from a symlinked directory +--- + +# Shared Skill + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("shared-skill") + expect(skills[0].source).toBe("global") + }) + + it("should handle symlinked skill subdirectory", async () => { + const myAliasDir = p(globalSkillsDir, "my-alias") + const myAliasMd = p(myAliasDir, "SKILL.md") + + // Simulate .roo/skills/my-alias being a symlink to /external/actual-skill + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["my-alias"] + } + return [] + }) + + // fs.stat follows symlinks, so it returns the target directory info + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === myAliasDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === myAliasMd + }) + + // The skill name in frontmatter must match the symlink name (my-alias) + mockReadFile.mockImplementation(async (file: string) => { + if (file === myAliasMd) { + return `--- +name: my-alias +description: A skill accessed via symlink +--- + +# My Alias Skill + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("my-alias") + expect(skills[0].source).toBe("global") + }) + }) + + describe("getSkillsForMode", () => { + it("should return skills filtered by mode", async () => { + const genericSkillDir = p(globalSkillsDir, "generic-skill") + const codeSkillDir = p(globalSkillsCodeDir, "code-skill") + + // Setup skills for testing + mockDirectoryExists.mockImplementation(async (dir: string) => { + return [globalSkillsDir, globalSkillsCodeDir].includes(dir) + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["generic-skill"] + } + if (dir === globalSkillsCodeDir) { + return ["code-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === genericSkillDir || pathArg === codeSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockResolvedValue(true) + + mockReadFile.mockImplementation(async (file: string) => { + if (file.includes("generic-skill")) { + return `--- +name: generic-skill +description: Generic skill +--- +Instructions` + } + if (file.includes("code-skill")) { + return `--- +name: code-skill +description: Code skill +--- +Instructions` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const codeSkills = skillsManager.getSkillsForMode("code") + + // Should include both generic and code-specific skills + expect(codeSkills.length).toBe(2) + expect(codeSkills.map((s) => s.name)).toContain("generic-skill") + expect(codeSkills.map((s) => s.name)).toContain("code-skill") + }) + + it("should apply project > global override", async () => { + const globalSharedSkillDir = p(globalSkillsDir, "shared-skill") + const projectSharedSkillDir = p(projectSkillsDir, "shared-skill") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return [globalSkillsDir, projectSkillsDir].includes(dir) + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["shared-skill"] + } + if (dir === projectSkillsDir) { + return ["shared-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === globalSharedSkillDir || pathArg === projectSharedSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockResolvedValue(true) + + mockReadFile.mockResolvedValue(`--- +name: shared-skill +description: Shared skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getSkillsForMode("code") + const sharedSkill = skills.find((s) => s.name === "shared-skill") + + // Project skill should override global + expect(sharedSkill?.source).toBe("project") + }) + + it("should apply mode-specific > generic override", async () => { + const genericTestSkillDir = p(globalSkillsDir, "test-skill") + const codeTestSkillDir = p(globalSkillsCodeDir, "test-skill") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return [globalSkillsDir, globalSkillsCodeDir].includes(dir) + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + if (dir === globalSkillsCodeDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === genericTestSkillDir || pathArg === codeTestSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockResolvedValue(true) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: Test skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getSkillsForMode("code") + const testSkill = skills.find((s) => s.name === "test-skill") + + // Mode-specific should override generic + expect(testSkill?.mode).toBe("code") + }) + + it("should not include mode-specific skills for other modes", async () => { + const architectOnlyDir = p(globalSkillsArchitectDir, "architect-only") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsArchitectDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsArchitectDir) { + return ["architect-only"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === architectOnlyDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockResolvedValue(true) + + mockReadFile.mockResolvedValue(`--- +name: architect-only +description: Only for architect mode +--- +Instructions`) + + await skillsManager.discoverSkills() + + const codeSkills = skillsManager.getSkillsForMode("code") + const architectSkill = codeSkills.find((s) => s.name === "architect-only") + + expect(architectSkill).toBeUndefined() + }) + }) + + describe("getSkillContent", () => { + it("should return full skill content", async () => { + const testSkillDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(testSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === testSkillMd + }) + + const skillContent = `--- +name: test-skill +description: A test skill +--- + +# Test Skill + +## Instructions + +1. Do this +2. Do that` + + mockReadFile.mockResolvedValue(skillContent) + + await skillsManager.discoverSkills() + + const content = await skillsManager.getSkillContent("test-skill") + + expect(content).not.toBeNull() + expect(content?.name).toBe("test-skill") + expect(content?.instructions).toContain("# Test Skill") + expect(content?.instructions).toContain("1. Do this") + }) + + it("should return null for non-existent skill", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + + await skillsManager.discoverSkills() + + const content = await skillsManager.getSkillContent("non-existent") + + expect(content).toBeNull() + }) + }) + + describe("dispose", () => { + it("should clean up resources", async () => { + await skillsManager.dispose() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + }) +}) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 093485fa3e..2eec4cb6c8 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -94,9 +94,6 @@ export interface ExtensionMessage { | "deleteCustomModeCheck" | "currentCheckpointUpdated" | "checkpointInitWarning" - | "showHumanRelayDialog" - | "humanRelayResponse" - | "humanRelayCancel" | "browserToolEnabled" | "browserConnectionResult" | "remoteBrowserEnabled" @@ -311,6 +308,7 @@ export type ExtensionState = Pick< maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500) maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500) showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings + enableSubfolderRules: boolean // Whether to load rules from subdirectories maxReadFileLine: number // Maximum number of lines to read from a file before truncating maxImageFileSize: number // Maximum size of image files to process in MB maxTotalImageSize: number // Maximum total size for all images in a single read operation in MB @@ -335,6 +333,7 @@ export type ExtensionState = Pick< cloudUserInfo: CloudUserInfo | null cloudIsAuthenticated: boolean + cloudAuthSkipModel?: boolean // Flag indicating auth completed without model selection (user should pick 3rd-party provider) cloudApiUrl?: string cloudOrganizations?: CloudOrganizationMembership[] sharingEnabled: boolean @@ -351,7 +350,6 @@ export type ExtensionState = Pick< profileThresholds: Record hasOpenedModeSelector: boolean openRouterImageApiKey?: string - openRouterUseMiddleOutTransform?: boolean messageQueue?: QueuedMessage[] lastShownAnnouncementId?: string apiModelId?: string diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index c8a2c243c0..3ca5b5616d 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -14,10 +14,6 @@ export class ProfileValidator { return false } - if (profile.apiProvider === "human-relay") { - return true - } - const modelId = this.getModelIdFromProfile(profile) if (!modelId) { @@ -90,7 +86,6 @@ export class ProfileValidator { return profile.ioIntelligenceModelId case "deepinfra": return profile.deepInfraModelId - case "human-relay": case "fake-ai": default: return undefined diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 6c87815994..4c3e321dea 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -113,8 +113,6 @@ export interface WebviewMessage { | "checkpointDiff" | "checkpointRestore" | "deleteMcpServer" - | "humanRelayResponse" - | "humanRelayCancel" | "codebaseIndexEnabled" | "telemetrySetting" | "testBrowserConnection" @@ -122,6 +120,7 @@ export interface WebviewMessage { | "searchFiles" | "toggleApiConfigPin" | "hasOpenedModeSelector" + | "clearCloudAuthSkipModel" | "cloudButtonClicked" | "rooCloudSignIn" | "cloudLandingPageSignIn" diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index d604cd9523..04bd171696 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -47,20 +47,6 @@ describe("ProfileValidator", () => { expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(false) }) - it("should allow human-relay provider regardless of model", () => { - const allowList: OrganizationAllowList = { - allowAll: false, - providers: { - "human-relay": { allowAll: false }, - }, - } - const profile: ProviderSettings = { - apiProvider: "human-relay", - } - - expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) - }) - it("should allow providers with allowAll=true regardless of model", () => { const allowList: OrganizationAllowList = { allowAll: false, diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index 4b9af08d5a..37b468ce1a 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -5,11 +5,8 @@ export function checkExistKey(config: ProviderSettings | undefined) { return false } - // Special case for human-relay, fake-ai, claude-code, qwen-code, and roo providers which don't need any configuration. - if ( - config.apiProvider && - ["human-relay", "fake-ai", "claude-code", "qwen-code", "roo"].includes(config.apiProvider) - ) { + // Special case for fake-ai, claude-code, qwen-code, and roo providers which don't need any configuration. + if (config.apiProvider && ["fake-ai", "claude-code", "qwen-code", "roo"].includes(config.apiProvider)) { return true } diff --git a/src/shared/skills.ts b/src/shared/skills.ts new file mode 100644 index 0000000000..7ed85816aa --- /dev/null +++ b/src/shared/skills.ts @@ -0,0 +1,18 @@ +/** + * Skill metadata for discovery (loaded at startup) + * Only name and description are required for now + */ +export interface SkillMetadata { + name: string // Required: skill identifier + description: string // Required: when to use this skill + path: string // Absolute path to SKILL.md + source: "global" | "project" // Where the skill was discovered + mode?: string // If set, skill is only available in this mode +} + +/** + * Full skill content (loaded on activation) + */ +export interface SkillContent extends SkillMetadata { + instructions: string // Full markdown body +} diff --git a/src/shared/string-extensions.d.ts b/src/shared/string-extensions.d.ts new file mode 100644 index 0000000000..aad87e4867 --- /dev/null +++ b/src/shared/string-extensions.d.ts @@ -0,0 +1,25 @@ +/** + * Global string extensions declaration. + * This file provides type declarations for String.prototype extensions + * that are used across the codebase. + * + * The actual implementation is in src/utils/path.ts. + * + * This separate declaration file is necessary because the webview-ui package + * includes ../src/shared in its tsconfig.json but not ../src/utils/path.ts. + * Without this file, the webview-ui compilation would fail when processing + * files that use the toPosix() method. + */ +declare global { + interface String { + /** + * Convert a path string to POSIX format (forward slashes). + * Extended-Length Paths in Windows (\\?\) are preserved. + * @returns The path with backslashes converted to forward slashes + */ + toPosix(): string + } +} + +// This export is needed to make this file a module +export {} diff --git a/src/shared/tools.ts b/src/shared/tools.ts index f2b4ec3544..f893a3d332 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -266,6 +266,7 @@ export const TOOL_DISPLAY_NAMES: Record = { update_todo_list: "update todo list", run_slash_command: "run slash command", generate_image: "generate images", + custom_tool: "use custom tools", } as const // Define available tool groups. diff --git a/src/types/global-agent.d.ts b/src/types/global-agent.d.ts new file mode 100644 index 0000000000..1dba1e38e1 --- /dev/null +++ b/src/types/global-agent.d.ts @@ -0,0 +1,47 @@ +/** + * Type declarations for global-agent package. + * + * global-agent is a library that creates a global HTTP/HTTPS agent + * that routes all traffic through a specified proxy. + * + * @see https://github.com/gajus/global-agent + */ + +declare module "global-agent" { + /** + * Bootstrap global-agent to intercept all HTTP/HTTPS requests. + * + * After calling this function, all outgoing HTTP/HTTPS requests + * from the Node.js process will be routed through the proxy + * specified by the GLOBAL_AGENT_HTTP_PROXY and GLOBAL_AGENT_HTTPS_PROXY + * environment variables. + * + * @returns void + */ + export function bootstrap(): void + + /** + * Create a global agent with custom configuration. + * + * @param options Configuration options for the global agent + * @returns void + */ + export function createGlobalProxyAgent(options?: { + /** + * Environment variable namespace prefix. + * Default: "GLOBAL_AGENT_" + */ + environmentVariableNamespace?: string + + /** + * Force global agent to be used for all HTTP/HTTPS requests. + * Default: true + */ + forceGlobalAgent?: boolean + + /** + * Socket connection timeout in milliseconds. + */ + socketConnectionTimeout?: number + }): void +} diff --git a/src/utils/__tests__/networkProxy.spec.ts b/src/utils/__tests__/networkProxy.spec.ts new file mode 100644 index 0000000000..97c046d1b0 --- /dev/null +++ b/src/utils/__tests__/networkProxy.spec.ts @@ -0,0 +1,308 @@ +import * as vscode from "vscode" +import { initializeNetworkProxy, getProxyConfig, isProxyEnabled, isDebugMode } from "../networkProxy" + +// Mock global-agent +vi.mock("global-agent", () => ({ + bootstrap: vi.fn(), +})) + +// Mock vscode +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(), + onDidChangeConfiguration: vi.fn(() => ({ dispose: vi.fn() })), + }, + ExtensionMode: { + Development: 2, + Production: 1, + Test: 3, + }, +})) + +describe("networkProxy", () => { + let mockOutputChannel: vscode.OutputChannel + let mockConfig: { get: ReturnType } + + // Helper to create mock context with configurable extensionMode + function createMockContext(mode: vscode.ExtensionMode = vscode.ExtensionMode.Production): vscode.ExtensionContext { + return { + extensionMode: mode, + subscriptions: [], + extensionPath: "/test/path", + globalState: { + get: vi.fn(), + update: vi.fn(), + keys: vi.fn().mockReturnValue([]), + setKeysForSync: vi.fn(), + }, + workspaceState: { + get: vi.fn(), + update: vi.fn(), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn(), + store: vi.fn(), + delete: vi.fn(), + onDidChange: vi.fn(), + }, + extensionUri: { fsPath: "/test/path" } as vscode.Uri, + globalStorageUri: { fsPath: "/test/global" } as vscode.Uri, + logUri: { fsPath: "/test/logs" } as vscode.Uri, + storageUri: { fsPath: "/test/storage" } as vscode.Uri, + storagePath: "/test/storage", + globalStoragePath: "/test/global", + logPath: "/test/logs", + asAbsolutePath: vi.fn((p) => `/test/path/${p}`), + environmentVariableCollection: {} as vscode.GlobalEnvironmentVariableCollection, + extension: {} as vscode.Extension, + languageModelAccessInformation: {} as vscode.LanguageModelAccessInformation, + } as unknown as vscode.ExtensionContext + } + + beforeEach(() => { + vi.clearAllMocks() + + // Reset environment variables + delete process.env.GLOBAL_AGENT_HTTP_PROXY + delete process.env.GLOBAL_AGENT_HTTPS_PROXY + delete process.env.GLOBAL_AGENT_NO_PROXY + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + + mockConfig = { + get: vi.fn().mockReturnValue(""), + } + + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue( + mockConfig as unknown as vscode.WorkspaceConfiguration, + ) + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + name: "Test", + replace: vi.fn(), + } as unknown as vscode.OutputChannel + }) + + describe("initializeNetworkProxy", () => { + it("should initialize without proxy when debugProxy.enabled is false", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return false + if (key === "debugProxy.serverUrl") return "http://127.0.0.1:8888" + return "" + }) + const context = createMockContext() + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(process.env.GLOBAL_AGENT_HTTP_PROXY).toBeUndefined() + expect(process.env.GLOBAL_AGENT_HTTPS_PROXY).toBeUndefined() + }) + + it("should configure proxy environment variables when debugProxy.enabled is true", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + return "" + }) + // Proxy is only applied in debug mode. + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(process.env.GLOBAL_AGENT_HTTP_PROXY).toBe("http://localhost:8080") + expect(process.env.GLOBAL_AGENT_HTTPS_PROXY).toBe("http://localhost:8080") + }) + + it("should not modify TLS settings in debug mode by default", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + if (key === "debugProxy.tlsInsecure") return false + return "" + }) + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined() + }) + + it("should disable TLS verification when tlsInsecure is enabled (debug mode only)", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + if (key === "debugProxy.tlsInsecure") return true + return "" + }) + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBe("0") + }) + + it("should register configuration change listener in debug mode", () => { + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(vscode.workspace.onDidChangeConfiguration).toHaveBeenCalled() + expect(context.subscriptions.length).toBeGreaterThan(0) + }) + + it("should not register listeners in production mode (early exit)", () => { + const context = createMockContext(vscode.ExtensionMode.Production) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(vscode.workspace.onDidChangeConfiguration).not.toHaveBeenCalled() + expect(context.subscriptions.length).toBe(0) + }) + + it("should not throw in non-debug mode if proxy deps are not installed", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + return "" + }) + const context = createMockContext(vscode.ExtensionMode.Production) + + expect(() => { + void initializeNetworkProxy(context, mockOutputChannel) + }).not.toThrow() + }) + }) + + describe("getProxyConfig", () => { + it("should return default config before initialization", () => { + // Reset the module to clear internal state + vi.resetModules() + + const config = getProxyConfig() + + expect(config.enabled).toBe(false) + expect(config.serverUrl).toBe("http://127.0.0.1:8888") // default value + expect(config.isDebugMode).toBe(false) + }) + + it("should return correct config after initialization", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://proxy.example.com:3128" + if (key === "debugProxy.tlsInsecure") return true + return "" + }) + const context = createMockContext(vscode.ExtensionMode.Production) + + void initializeNetworkProxy(context, mockOutputChannel) + const config = getProxyConfig() + + expect(config.enabled).toBe(true) + expect(config.serverUrl).toBe("http://proxy.example.com:3128") + expect(config.tlsInsecure).toBe(true) + expect(config.isDebugMode).toBe(false) + }) + + it("should trim whitespace from server URL", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.serverUrl") return " http://proxy.example.com:3128 " + return "" + }) + const context = createMockContext() + + void initializeNetworkProxy(context, mockOutputChannel) + const config = getProxyConfig() + + expect(config.serverUrl).toBe("http://proxy.example.com:3128") + }) + + it("should return default URL for empty server URL", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.serverUrl") return " " + return "" + }) + const context = createMockContext() + + void initializeNetworkProxy(context, mockOutputChannel) + const config = getProxyConfig() + + expect(config.serverUrl).toBe("http://127.0.0.1:8888") // falls back to default + }) + }) + + describe("isProxyEnabled", () => { + it("should return false when proxy is not enabled", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return false + return "" + }) + const context = createMockContext() + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(isProxyEnabled()).toBe(false) + }) + + it("should return true when proxy is enabled in debug mode", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + return "" + }) + // Proxy is only applied in debug mode. + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(isProxyEnabled()).toBe(true) + }) + }) + + describe("isDebugMode", () => { + it("should return false in production mode", () => { + const context = createMockContext(vscode.ExtensionMode.Production) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(isDebugMode()).toBe(false) + }) + + it("should return true in development mode", () => { + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(isDebugMode()).toBe(true) + }) + + // Note: This test is skipped because module state persists across tests. + // In a real scenario, isDebugMode() returns false before any initialization. + // The actual behavior is verified in integration testing. + it.skip("should return false before initialization", () => { + // This would require full module isolation which isn't practical here + expect(isDebugMode()).toBe(false) + }) + }) + + describe("security", () => { + it("should not disable TLS verification unless tlsInsecure is enabled", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + if (key === "debugProxy.tlsInsecure") return false + return "" + }) + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined() + }) + }) +}) diff --git a/src/utils/networkProxy.ts b/src/utils/networkProxy.ts new file mode 100644 index 0000000000..448bc1b576 --- /dev/null +++ b/src/utils/networkProxy.ts @@ -0,0 +1,364 @@ +/** + * Network Proxy Configuration Module + * + * Provides proxy configuration for all outbound HTTP/HTTPS requests from the Roo Code extension. + * When running in debug mode (F5), a proxy can be enabled for outbound traffic. + * Optionally, TLS certificate verification can be disabled (debug only) to allow + * MITM proxy inspection. + * + * Uses global-agent to globally route all HTTP/HTTPS traffic through the proxy, + * which works with axios, fetch, and most SDKs that use native Node.js http/https. + */ + +import * as vscode from "vscode" +import { Package } from "../shared/package" + +/** + * Proxy configuration state + */ +export interface ProxyConfig { + /** Whether the debug proxy is enabled */ + enabled: boolean + /** The proxy server URL (e.g., http://127.0.0.1:8888) */ + serverUrl: string + /** Accept self-signed/insecure TLS certificates from the proxy (required for MITM) */ + tlsInsecure: boolean + /** Whether running in debug/development mode */ + isDebugMode: boolean +} + +let extensionContext: vscode.ExtensionContext | null = null +let proxyInitialized = false +let undiciProxyInitialized = false +let fetchPatched = false +let originalFetch: typeof fetch | undefined +let outputChannel: vscode.OutputChannel | null = null + +let loggingEnabled = false +let consoleLoggingEnabled = false + +let tlsVerificationOverridden = false +let originalNodeTlsRejectUnauthorized: string | undefined + +function redactProxyUrl(proxyUrl: string | undefined): string { + if (!proxyUrl) { + return "(not set)" + } + + try { + const url = new URL(proxyUrl) + url.username = "" + url.password = "" + return url.toString() + } catch { + // Fallback for invalid URLs: redact basic auth if present. + return proxyUrl.replace(/\/\/[^@/]+@/g, "//REDACTED@") + } +} + +function restoreGlobalFetchPatch(): void { + if (!fetchPatched) { + return + } + + if (originalFetch) { + globalThis.fetch = originalFetch + } + + fetchPatched = false + originalFetch = undefined +} + +function restoreTlsVerificationOverride(): void { + if (!tlsVerificationOverridden) { + return + } + + if (typeof originalNodeTlsRejectUnauthorized === "string") { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalNodeTlsRejectUnauthorized + } else { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + } + + tlsVerificationOverridden = false + originalNodeTlsRejectUnauthorized = undefined +} + +function applyTlsVerificationOverride(config: ProxyConfig): void { + // Only relevant in debug mode with an active proxy. + if (!config.isDebugMode || !config.enabled) { + restoreTlsVerificationOverride() + return + } + + if (!config.tlsInsecure) { + restoreTlsVerificationOverride() + return + } + + if (!tlsVerificationOverridden) { + originalNodeTlsRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED + } + + // CodeQL: debug-only opt-in for MITM debugging. + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" // lgtm[js/disabling-certificate-validation] + tlsVerificationOverridden = true +} + +/** + * Initialize the network proxy module with the extension context. + * Must be called early in extension activation before any network requests. + * + * @param context The VS Code extension context + * @param channel Optional output channel for logging + */ +export async function initializeNetworkProxy( + context: vscode.ExtensionContext, + channel?: vscode.OutputChannel, +): Promise { + extensionContext = context + + // extensionMode is immutable for the process lifetime - exit early if not in debug mode. + // This avoids any overhead (listeners, logging, etc.) in production. + const isDebugMode = context.extensionMode === vscode.ExtensionMode.Development + if (!isDebugMode) { + return + } + + outputChannel = channel ?? null + loggingEnabled = true + consoleLoggingEnabled = !outputChannel + + const config = getProxyConfig() + + log(`Initializing network proxy module...`) + log( + `Proxy config: enabled=${config.enabled}, serverUrl=${redactProxyUrl(config.serverUrl)}, tlsInsecure=${config.tlsInsecure}`, + ) + + // Listen for configuration changes to allow toggling proxy during a debug session. + // Guard for test environments where onDidChangeConfiguration may not be mocked. + if (typeof vscode.workspace.onDidChangeConfiguration === "function") { + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if ( + e.affectsConfiguration(`${Package.name}.debugProxy.enabled`) || + e.affectsConfiguration(`${Package.name}.debugProxy.serverUrl`) || + e.affectsConfiguration(`${Package.name}.debugProxy.tlsInsecure`) + ) { + const newConfig = getProxyConfig() + + if (newConfig.enabled) { + applyTlsVerificationOverride(newConfig) + configureGlobalProxy(newConfig) + configureUndiciProxy(newConfig) + } else { + // Proxy disabled - but we can't easily un-bootstrap global-agent or reset undici dispatcher safely. + // We *can* restore any global fetch patch immediately. + restoreGlobalFetchPatch() + restoreTlsVerificationOverride() + log("Debug proxy disabled. Restart VS Code to fully disable proxy routing.") + } + } + }), + ) + } + + // Ensure we restore any overrides when the extension unloads. + context.subscriptions.push({ + dispose: () => { + restoreGlobalFetchPatch() + restoreTlsVerificationOverride() + }, + }) + + if (config.enabled) { + applyTlsVerificationOverride(config) + await configureGlobalProxy(config) + await configureUndiciProxy(config) + } else { + log(`Debug proxy not enabled.`) + } +} + +/** + * Get the current proxy configuration based on VS Code settings and extension mode. + */ +export function getProxyConfig(): ProxyConfig { + const defaultServerUrl = "http://127.0.0.1:8888" + + if (!extensionContext) { + // Fallback if called before initialization + return { + enabled: false, + serverUrl: defaultServerUrl, + tlsInsecure: false, + isDebugMode: false, + } + } + + const config = vscode.workspace.getConfiguration(Package.name) + const enabled = Boolean(config.get("debugProxy.enabled")) + const rawServerUrl = config.get("debugProxy.serverUrl") + const serverUrl = typeof rawServerUrl === "string" && rawServerUrl.trim() ? rawServerUrl.trim() : defaultServerUrl + const tlsInsecure = Boolean(config.get("debugProxy.tlsInsecure")) + + // Debug mode only. + const isDebugMode = extensionContext.extensionMode === vscode.ExtensionMode.Development + + return { + enabled, + serverUrl, + tlsInsecure, + isDebugMode, + } +} + +/** + * Configure global-agent to route all HTTP/HTTPS traffic through the proxy. + */ +async function configureGlobalProxy(config: ProxyConfig): Promise { + if (proxyInitialized) { + // global-agent can only be bootstrapped once + // Update environment variables for any new connections + log(`Proxy already initialized, updating env vars only`) + updateProxyEnvVars(config) + return + } + + // Set up environment variables before bootstrapping + log(`Setting proxy environment variables before bootstrap (values redacted)...`) + updateProxyEnvVars(config) + + let bootstrap: (() => void) | undefined + try { + const mod = (await import("global-agent")) as typeof import("global-agent") + bootstrap = mod.bootstrap + } catch (error) { + log( + `Failed to load global-agent (proxy support is only available in debug/dev builds): ${error instanceof Error ? error.message : String(error)}`, + ) + return + } + + // Bootstrap global-agent to intercept all HTTP/HTTPS requests + log(`Calling global-agent bootstrap()...`) + try { + bootstrap() + proxyInitialized = true + log(`global-agent bootstrap() completed successfully`) + } catch (error) { + log(`global-agent bootstrap() FAILED: ${error instanceof Error ? error.message : String(error)}`) + return + } + + log(`Network proxy configured: ${redactProxyUrl(config.serverUrl)}`) +} + +/** + * Configure undici's global dispatcher so Node's built-in `fetch()` and any undici-based + * clients route through the proxy. + */ +async function configureUndiciProxy(config: ProxyConfig): Promise { + if (!config.enabled || !config.serverUrl) { + return + } + + if (undiciProxyInitialized) { + log(`undici global dispatcher already configured; restart VS Code to change proxy safely`) + return + } + + try { + const { + ProxyAgent, + setGlobalDispatcher, + fetch: undiciFetch, + } = (await import("undici")) as typeof import("undici") + + const proxyAgent = new ProxyAgent({ + uri: config.serverUrl, + // If the user enabled TLS insecure mode (debug only), apply it to undici. + requestTls: config.tlsInsecure + ? ({ rejectUnauthorized: false } satisfies import("tls").ConnectionOptions) // lgtm[js/disabling-certificate-validation] + : undefined, + proxyTls: config.tlsInsecure + ? ({ rejectUnauthorized: false } satisfies import("tls").ConnectionOptions) // lgtm[js/disabling-certificate-validation] + : undefined, + }) + setGlobalDispatcher(proxyAgent) + undiciProxyInitialized = true + log(`undici global dispatcher configured for proxy: ${redactProxyUrl(config.serverUrl)}`) + + // Node's built-in `fetch()` (Node 18+) is powered by an internal undici copy. + // Setting a dispatcher on our `undici` dependency does NOT affect that internal fetch. + // To ensure Roo Code's `fetch()` calls are proxied, patch global fetch in debug mode. + // This patch is scoped to the extension lifecycle (restored on deactivate) and can be restored + // immediately if the proxy is disabled. + if (!fetchPatched) { + if (typeof globalThis.fetch === "function") { + originalFetch = globalThis.fetch + } + + globalThis.fetch = undiciFetch as unknown as typeof fetch + fetchPatched = true + log(`globalThis.fetch patched to undici.fetch (debug proxy mode)`) + + if (extensionContext) { + extensionContext.subscriptions.push({ + dispose: () => restoreGlobalFetchPatch(), + }) + } + } + } catch (error) { + log(`Failed to configure undici proxy dispatcher: ${error instanceof Error ? error.message : String(error)}`) + } +} +/** + * Update environment variables for proxy configuration. + * global-agent reads from GLOBAL_AGENT_* environment variables. + */ +function updateProxyEnvVars(config: ProxyConfig): void { + if (config.serverUrl) { + // global-agent uses these environment variables + process.env.GLOBAL_AGENT_HTTP_PROXY = config.serverUrl + process.env.GLOBAL_AGENT_HTTPS_PROXY = config.serverUrl + process.env.GLOBAL_AGENT_NO_PROXY = "" // Proxy all requests + } +} + +/** + * Check if a proxy is currently configured and active. + */ +export function isProxyEnabled(): boolean { + const config = getProxyConfig() + // Active proxy is only applied in debug mode. + return config.enabled && config.isDebugMode +} + +/** + * Check if we're running in debug mode. + */ +export function isDebugMode(): boolean { + if (!extensionContext) { + return false + } + return extensionContext.extensionMode === vscode.ExtensionMode.Development +} + +/** + * Log a message to the output channel if available. + */ +function log(message: string): void { + if (!loggingEnabled) { + return + } + + const logMessage = `[NetworkProxy] ${message}` + if (outputChannel) { + outputChannel.appendLine(logMessage) + } + if (consoleLoggingEnabled) { + console.log(logMessage) + } +} diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index dbf26be7c5..04d9b76f2c 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -16,7 +16,6 @@ import HistoryView from "./components/history/HistoryView" import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView" import WelcomeView from "./components/welcome/WelcomeViewProvider" import { MarketplaceView } from "./components/marketplace/MarketplaceView" -import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog" import { CheckpointRestoreDialog } from "./components/chat/CheckpointRestoreDialog" import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog" import ErrorBoundary from "./components/ErrorBoundary" @@ -27,12 +26,6 @@ import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip" type Tab = "settings" | "history" | "chat" | "marketplace" | "cloud" -interface HumanRelayDialogState { - isOpen: boolean - requestId: string - promptText: string -} - interface DeleteMessageDialogState { isOpen: boolean messageTs: number @@ -51,8 +44,6 @@ interface EditMessageDialogState { const MemoizedDeleteMessageDialog = React.memo(DeleteMessageDialog) const MemoizedEditMessageDialog = React.memo(EditMessageDialog) const MemoizedCheckpointRestoreDialog = React.memo(CheckpointRestoreDialog) -const MemoizedHumanRelayDialog = React.memo(HumanRelayDialog) - const tabsByMessageAction: Partial, Tab>> = { chatButtonClicked: "chat", settingsButtonClicked: "settings", @@ -83,12 +74,6 @@ const App = () => { const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") - const [humanRelayDialogState, setHumanRelayDialogState] = useState({ - isOpen: false, - requestId: "", - promptText: "", - }) - const [deleteMessageDialogState, setDeleteMessageDialogState] = useState({ isOpen: false, messageTs: 0, @@ -158,11 +143,6 @@ const App = () => { } } - if (message.type === "showHumanRelayDialog" && message.requestId && message.promptText) { - const { requestId, promptText } = message - setHumanRelayDialogState({ isOpen: true, requestId, promptText }) - } - if (message.type === "showDeleteMessageDialog" && message.messageTs) { setDeleteMessageDialogState({ isOpen: true, @@ -271,14 +251,6 @@ const App = () => { showAnnouncement={showAnnouncement} hideAnnouncement={() => setShowAnnouncement(false)} /> - setHumanRelayDialogState((prev) => ({ ...prev, isOpen: false }))} - onSubmit={(requestId, text) => vscode.postMessage({ type: "humanRelayResponse", requestId, text })} - onCancel={(requestId) => vscode.postMessage({ type: "humanRelayCancel", requestId })} - /> {deleteMessageDialogState.hasCheckpoint ? ( ({ const mockUseExtensionState = vi.fn() -// Mock the HumanRelayDialog component -vi.mock("@src/components/human-relay/HumanRelayDialog", () => ({ - HumanRelayDialog: ({ _children, isOpen, onClose }: any) => ( -

    - Human Relay Dialog -
    - ), -})) - // Mock i18next and react-i18next vi.mock("i18next", () => { const tFunction = (key: string) => key diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index f69e18065a..490d0f2d68 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -44,9 +44,9 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {

    {t("chat:announcement.release.heading")}

    diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index bea896a6d9..b6cfb8de2d 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -300,6 +300,8 @@ export const ChatRowContent = ({ />, {t("chat:taskCompleted")}, ] + case "api_req_rate_limit_wait": + return [] case "api_req_retry_delayed": return [] case "api_req_started": @@ -329,8 +331,10 @@ export const ChatRowContent = ({ getIconSpan("arrow-swap", normalColor) ) : apiRequestFailedMessage ? ( getIconSpan("error", errorColor) - ) : ( + ) : isLast ? ( + ) : ( + getIconSpan("arrow-swap", normalColor) ), apiReqCancelReason !== null && apiReqCancelReason !== undefined ? ( apiReqCancelReason === "user_cancelled" ? ( @@ -358,7 +362,17 @@ export const ChatRowContent = ({ default: return [null, null] } - }, [type, isCommandExecuting, message, isMcpServerResponding, apiReqCancelReason, cost, apiRequestFailedMessage, t]) + }, [ + type, + isCommandExecuting, + message, + isMcpServerResponding, + apiReqCancelReason, + cost, + apiRequestFailedMessage, + t, + isLast, + ]) const headerStyle: React.CSSProperties = { display: "flex", @@ -1151,6 +1165,35 @@ export const ChatRowContent = ({ errorDetails={rawError} /> ) + case "api_req_rate_limit_wait": { + const isWaiting = message.partial === true + + const waitSeconds = (() => { + if (!message.text) return undefined + try { + const data = JSON.parse(message.text) + return typeof data.seconds === "number" ? data.seconds : undefined + } catch { + return undefined + } + })() + + return isWaiting && waitSeconds !== undefined ? ( +
    +
    + + {t("chat:apiRequest.rateLimitWait")} +
    + {waitSeconds}s +
    + ) : null + } case "api_req_finished": return null // we should never see this message type case "text": @@ -1263,6 +1306,7 @@ export const ChatRowContent = ({ case "error": // Check if this is a model response error based on marker strings from backend const isNoToolsUsedError = message.text === "MODEL_NO_TOOLS_USED" + const isNoAssistantMessagesError = message.text === "MODEL_NO_ASSISTANT_MESSAGES" if (isNoToolsUsedError) { return ( @@ -1275,6 +1319,17 @@ export const ChatRowContent = ({ ) } + if (isNoAssistantMessagesError) { + return ( + + ) + } + // Fallback for generic errors return case "completion_result": diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index a002abf8cf..6f3ee16ec1 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -400,6 +400,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction 1) { if ( - lastMessage.text && // has text + typeof lastMessage.text === "string" && // has text (must be string for startsWith) (lastMessage.say === "text" || lastMessage.say === "completion_result") && // is a text message !lastMessage.partial && // not a partial message !lastMessage.text.startsWith("{") // not a json object diff --git a/webview-ui/src/components/chat/FollowUpSuggest.tsx b/webview-ui/src/components/chat/FollowUpSuggest.tsx index 6c7a8394b4..24f8a61d35 100644 --- a/webview-ui/src/components/chat/FollowUpSuggest.tsx +++ b/webview-ui/src/components/chat/FollowUpSuggest.tsx @@ -135,14 +135,14 @@ export const FollowUpSuggest = ({

    )} {suggestion.mode && ( -
    +
    {suggestion.mode}
    )}
    { e.stopPropagation() // Cancel the auto-approve timer when edit button is clicked diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx new file mode 100644 index 0000000000..d8ef8fad20 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx @@ -0,0 +1,78 @@ +import React from "react" + +import { render, screen } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { ChatRowContent } from "../ChatRow" + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const map: Record = { + "chat:apiRequest.rateLimitWait": "Rate limiting", + } + return map[key] ?? key + }, + }), + Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +const queryClient = new QueryClient() + +function renderChatRow(message: any) { + return render( + + + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + /> + + , + ) +} + +describe("ChatRow - rate limit wait", () => { + it("renders a non-error progress row for api_req_rate_limit_wait", () => { + const message: any = { + type: "say", + say: "api_req_rate_limit_wait", + ts: Date.now(), + partial: true, + text: JSON.stringify({ seconds: 1 }), + } + + renderChatRow(message) + + expect(screen.getByText("Rate limiting")).toBeInTheDocument() + // Should show countdown, but should NOT show the error-details affordance. + expect(screen.getByText("1s")).toBeInTheDocument() + expect(screen.queryByText("Details")).toBeNull() + }) + + it("renders nothing when rate limit wait is complete", () => { + const message: any = { + type: "say", + say: "api_req_rate_limit_wait", + ts: Date.now(), + partial: false, + text: undefined, + } + + const { container } = renderChatRow(message) + + // The row should be hidden when rate limiting is complete + expect(screen.queryByText("Rate limiting")).toBeNull() + // Nothing should be rendered + expect(container.firstChild).toBeNull() + }) +}) diff --git a/webview-ui/src/components/human-relay/HumanRelayDialog.tsx b/webview-ui/src/components/human-relay/HumanRelayDialog.tsx deleted file mode 100644 index a19a0037d0..0000000000 --- a/webview-ui/src/components/human-relay/HumanRelayDialog.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import * as React from "react" -import { Button } from "../ui/button" -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog" -import { Textarea } from "../ui/textarea" -import { useClipboard } from "../ui/hooks" -import { Check, Copy, X } from "lucide-react" -import { useAppTranslation } from "@/i18n/TranslationContext" - -interface HumanRelayDialogProps { - isOpen: boolean - onClose: () => void - requestId: string - promptText: string - onSubmit: (requestId: string, text: string) => void - onCancel: (requestId: string) => void -} - -/** - * Human Relay Dialog Component - * Displays the prompt text that needs to be copied and provides an input box for the user to paste the AI's response. - */ -export const HumanRelayDialog: React.FC = ({ - isOpen, - onClose, - requestId, - promptText, - onSubmit, - onCancel, -}) => { - const { t } = useAppTranslation() - const [response, setResponse] = React.useState("") - const { copy } = useClipboard() - const [isCopyClicked, setIsCopyClicked] = React.useState(false) - - // Clear input when dialog opens - React.useEffect(() => { - if (isOpen) { - setResponse("") - setIsCopyClicked(false) - } - }, [isOpen]) - - // Copy to clipboard and show success message - const handleCopy = () => { - copy(promptText) - setIsCopyClicked(true) - setTimeout(() => { - setIsCopyClicked(false) - }, 2000) - } - - // Submit response - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - if (response.trim()) { - onSubmit(requestId, response) - onClose() - } - } - - // Cancel operation - const handleCancel = () => { - onCancel(requestId) - onClose() - } - - return ( - !open && handleCancel()}> - - - {t("humanRelay:dialogTitle")} - {t("humanRelay:dialogDescription")} - - -
    -
    -